diff --git a/opendbc_repo/opendbc/car/ford/aol.py b/opendbc_repo/opendbc/car/ford/aol.py index a8cfa60247..0e4513423f 100644 --- a/opendbc_repo/opendbc/car/ford/aol.py +++ b/opendbc_repo/opendbc/car/ford/aol.py @@ -1,18 +1,43 @@ from opendbc.car import structs -from opendbc.car.ford.values import CAR, FordFlags +from opendbc.car.ford.values import CAR, FordFlags, FordSafetyFlags AOL_EXPERIENCE = 32 CLASSIC = frozenset((CAR.FORD_BRONCO_SPORT_MK1, CAR.FORD_ESCAPE_MK4, CAR.FORD_FOCUS_MK4, CAR.FORD_MAVERICK_MK1, CAR.FORD_EXPLORER_MK6)) GENERIC_CANFD = frozenset((CAR.FORD_ESCAPE_MK4_5, CAR.FORD_EXPEDITION_MK4, CAR.FORD_F_150_MK14, CAR.FORD_F_150_LIGHTNING_MK1, CAR.FORD_RANGER_MK2)) -AOL_WORDS = frozenset((8, 9, 10, 11, 12, 13, 18, 19, 32, 33, 66, 67)) +MANUAL = int(FordSafetyFlags.MANUAL) +BASE_WORDS = frozenset((8, 9, 10, 11, 12, 13, 18, 19, 32, 33, 66, 67)) +AOL_WORDS = BASE_WORDS | frozenset(word | MANUAL for word in BASE_WORDS) + + +def aol_safety_config(cp): + if len(cp.safetyConfigs) not in (1, 2): + return None + if len(cp.safetyConfigs) == 2: + passive = cp.safetyConfigs[0] + if passive.safetyModel != structs.CarParams.SafetyModel.noOutput or passive.safetyParam != 0: + return None + owner = cp.safetyConfigs[-1] + return owner if owner.safetyModel == structs.CarParams.SafetyModel.ford else None + + +def base_safety_param(cp): + owner = aol_safety_config(cp) + if owner is None: + return -1 + manual = cp.transmissionType == structs.CarParams.TransmissionType.manual + if cp.transmissionType not in (structs.CarParams.TransmissionType.automatic, structs.CarParams.TransmissionType.manual): + return -1 + word = int(owner.safetyParam) + if (word & MANUAL and not manual) or (manual and cp.alternativeExperience == AOL_EXPERIENCE and not word & MANUAL): + return -1 + return word & ~MANUAL def qualified(cp, *, marked_only=False): if (cp.brand != 'ford' or cp.carFingerprint not in tuple(CAR) or cp.passive or cp.notCar or cp.dashcamOnly or cp.steerControlType != structs.CarParams.SteerControlType.angle or - cp.transmissionType != structs.CarParams.TransmissionType.automatic or len(cp.safetyConfigs) != 1 or cp.alternativeExperience not in ((32,) if marked_only else (0, 32))): return False flags = int(cp.flags) & ~int(FordFlags.HAS_BSM) @@ -30,10 +55,9 @@ def qualified(cp, *, marked_only=False): base = 12 else: return False - safety = cp.safetyConfigs[0] - return (cp.pcmCruise and - safety.safetyModel == structs.CarParams.SafetyModel.ford and - safety.safetyParam == (base | int(cp.openpilotLongitudinalControl))) + safety = aol_safety_config(cp) + return bool(cp.pcmCruise and safety is not None and + base_safety_param(cp) == (base | int(cp.openpilotLongitudinalControl))) def temporary_restriction(cp, state): diff --git a/opendbc_repo/opendbc/car/ford/carstate.py b/opendbc_repo/opendbc/car/ford/carstate.py index e687e48467..b5a0d8ded6 100644 --- a/opendbc_repo/opendbc/car/ford/carstate.py +++ b/opendbc_repo/opendbc/car/ford/carstate.py @@ -215,6 +215,8 @@ class CarState(CarStateBase): gear = ("Gear_Shift_by_Wire_FD1" if CP.flags & FordFlags.CANFD else "TransGearData" if CP.flags & FordFlags.ALT_STEER_ANGLE else "PowertrainData_10") pt_messages.append((gear, 10)) + if CP.transmissionType == TransmissionType.manual: + pt_messages.append(("BCM_Lamp_Stat_FD1", 10)) if CP.flags & FordFlags.LKA_STEERING: pt_messages.append(("Lane_Assist_Data3_FD1", 30)) cam_messages.append(("LateralMotionControl", 20)) diff --git a/opendbc_repo/opendbc/car/ford/classic_lateral.py b/opendbc_repo/opendbc/car/ford/classic_lateral.py index d95fdb435b..053813ee68 100644 --- a/opendbc_repo/opendbc/car/ford/classic_lateral.py +++ b/opendbc_repo/opendbc/car/ford/classic_lateral.py @@ -6,6 +6,7 @@ CREDITS.md and THIRD_PARTY_NOTICES.md; this module only selects admitted callers """ from opendbc.car import structs +from opendbc.car.ford.aol import base_safety_param from opendbc.car.ford.explorer_lateral import ExplorerLateralController, bounded_command as bounded_command, qualified as explorer_qualified from opendbc.car.ford.lateral_strategy import FordLateralController from opendbc.car.ford.values import CAR, FordFlags, FordSafetyFlags @@ -16,8 +17,6 @@ CLASSIC_EXTENDED_CARS = GENERIC_CLASSIC_CARS | {CAR.FORD_EXPLORER_MK6} def qualified(cp): - if cp.alternativeExperience == 32 and len(cp.safetyConfigs) != 1: - return False if cp.carFingerprint == CAR.FORD_EXPLORER_MK6: return explorer_qualified(cp) if (cp.brand != "ford" or cp.carFingerprint not in GENERIC_CLASSIC_CARS or @@ -29,7 +28,7 @@ def qualified(cp): return False safety = cp.safetyConfigs[-1] expected = int(FordSafetyFlags.CLASSIC_EXTENDED | (FordSafetyFlags.LONG_CONTROL if cp.openpilotLongitudinalControl else 0)) - return safety.safetyModel == structs.CarParams.SafetyModel.ford and safety.safetyParam == expected + return safety.safetyModel == structs.CarParams.SafetyModel.ford and base_safety_param(cp) == expected class GenericClassicLateralController(FordLateralController): diff --git a/opendbc_repo/opendbc/car/ford/discovery.py b/opendbc_repo/opendbc/car/ford/discovery.py index f3697c83ca..16b79ac1fe 100644 --- a/opendbc_repo/opendbc/car/ford/discovery.py +++ b/opendbc_repo/opendbc/car/ford/discovery.py @@ -4,8 +4,8 @@ from opendbc.car.ford.values import CAR, FordFlags def normal_bus0_queries(): # Verify both configured and effective requests before omitting the OBD sweep. - # All essential and as-built requests remain on bus 0. The bus-1 logging - # request must be an exact duplicate of a bus-0 query, not unique evidence. + # Essential queries use normal bus0 and its exact auxiliary Panda bus4 copy. + # As-built remains bus0; bus1 logging cannot provide unique evidence. from opendbc.car import fw_versions configured = fw_versions.FW_QUERY_CONFIGS['ford'].requests effective = [request for brand, _, request in fw_versions.REQUESTS if brand == 'ford'] @@ -16,8 +16,17 @@ def normal_bus0_queries(): return (tuple(request.request), tuple(request.response), request.rx_offset, frozenset(request.whitelist_ecus)) for requests in (configured, effective): normal = {signature(request) for request in requests if request.bus == 0} - if not normal or any(request.bus != 0 and (not request.logging or signature(request) not in normal) - for request in requests): + essential = {signature(request) for request in requests if request.bus == 0 and not request.logging} + auxiliary = {signature(request) for request in requests if request.bus == 4 and not request.logging} + if not normal or not essential or auxiliary != essential: + return False + for request in requests: + if request.bus == 0: + continue + if request.bus == 4 and not request.logging and signature(request) in essential: + continue + if request.bus == 1 and request.logging and signature(request) in normal: + continue return False return True diff --git a/opendbc_repo/opendbc/car/ford/explorer_lateral.py b/opendbc_repo/opendbc/car/ford/explorer_lateral.py index 85db461a8b..ae88e9650b 100644 --- a/opendbc_repo/opendbc/car/ford/explorer_lateral.py +++ b/opendbc_repo/opendbc/car/ford/explorer_lateral.py @@ -11,6 +11,7 @@ from typing import Protocol import numpy as np from opendbc.car import structs +from opendbc.car.ford.aol import base_safety_param from opendbc.car.ford.lateral_strategy import FordLateralController, FordLateralResult, FORD_CURVATURE_LIMITS, STEER_DT from opendbc.car.ford.values import CAR, CarControllerParams, FordFlags, FordSafetyFlags @@ -34,8 +35,6 @@ class ExplorerLateralController(FordLateralController): def qualified(CP) -> bool: - if CP.alternativeExperience == 32 and len(CP.safetyConfigs) != 1: - return False if (CP.brand != "ford" or CP.carFingerprint != CAR.FORD_EXPLORER_MK6 or CP.flags & ~int(FordFlags.HAS_BSM) or CP.passive or CP.dashcamOnly or CP.notCar or CP.alternativeExperience not in (0, 32) or len(CP.safetyConfigs) not in (1, 2)): @@ -45,7 +44,7 @@ def qualified(CP) -> bool: return False safety = CP.safetyConfigs[-1] expected = int(FordSafetyFlags.EXPLORER_EXTENDED | (FordSafetyFlags.LONG_CONTROL if CP.openpilotLongitudinalControl else 0)) - return safety.safetyModel == structs.CarParams.SafetyModel.ford and safety.safetyParam == expected + return safety.safetyModel == structs.CarParams.SafetyModel.ford and base_safety_param(CP) == expected class CurvatureOwner(Protocol): diff --git a/opendbc_repo/opendbc/car/ford/generic_canfd_lateral.py b/opendbc_repo/opendbc/car/ford/generic_canfd_lateral.py index e62a7b2826..204982e0d0 100644 --- a/opendbc_repo/opendbc/car/ford/generic_canfd_lateral.py +++ b/opendbc_repo/opendbc/car/ford/generic_canfd_lateral.py @@ -4,6 +4,7 @@ The reused BluePilot-derived strategy retains its original attribution in lateral_strategy.py, CREDITS.md and THIRD_PARTY_NOTICES.md. """ from opendbc.car import structs +from opendbc.car.ford.aol import base_safety_param from opendbc.car.ford.explorer_lateral import bounded_command as shared_bound from opendbc.car.ford.lateral_strategy import FordLateralController from opendbc.car.ford.values import CAR, FordFlags, FordSafetyFlags @@ -14,8 +15,6 @@ SOURCE_ACCEL_CAP = 3.0 - 9.81 * .06 def qualified(cp): - if cp.alternativeExperience == 32 and len(cp.safetyConfigs) != 1: - return False if (cp.brand != "ford" or cp.carFingerprint not in GENERIC_CANFD_CARS or not cp.flags & FordFlags.CANFD or cp.flags & ~int(FordFlags.CANFD | FordFlags.HAS_BSM) or cp.passive or cp.dashcamOnly or cp.notCar or cp.alternativeExperience not in (0, 32) or @@ -27,7 +26,7 @@ def qualified(cp): s = cp.safetyConfigs[-1] expected = int(FordSafetyFlags.GENERIC_CANFD_EXTENDED | FordSafetyFlags.CANFD | (FordSafetyFlags.LONG_CONTROL if cp.openpilotLongitudinalControl else 0)) - return s.safetyModel == structs.CarParams.SafetyModel.ford and s.safetyParam == expected + return s.safetyModel == structs.CarParams.SafetyModel.ford and base_safety_param(cp) == expected class GenericCanfdLateralController(FordLateralController): diff --git a/opendbc_repo/opendbc/car/ford/interface.py b/opendbc_repo/opendbc/car/ford/interface.py index f7b0676173..e983765c0e 100644 --- a/opendbc_repo/opendbc/car/ford/interface.py +++ b/opendbc_repo/opendbc/car/ford/interface.py @@ -55,12 +55,12 @@ class CarInterface(CarInterfaceBase): new_port = bool(ret.flags & FordFlags.NEW_PORT) if candidate in GENERIC_CANFD_CARS: - ret.alphaLongitudinalAvailable = not is_release + ret.alphaLongitudinalAvailable = True ret.steerActuatorDelay = 0.22 - longitudinal_requested = bool(alpha_long and not is_release) + longitudinal_requested = bool(alpha_long) elif candidate == CAR.FORD_MUSTANG_MACH_E_MK1: - ret.alphaLongitudinalAvailable = not is_release - longitudinal_requested = bool(alpha_long and not is_release) + ret.alphaLongitudinalAvailable = True + longitudinal_requested = bool(alpha_long) elif candidate in CLASSIC_EXTENDED_CARS: # Original shared classic ownership is explicit stock/OP-long selection. # Actuator delay is live; modern LongControl already supplies the original P=0. @@ -68,7 +68,7 @@ class CarInterface(CarInterfaceBase): ret.steerActuatorDelay = 0.22 longitudinal_requested = bool(alpha_long) else: - ret.alphaLongitudinalAvailable = (not is_release if ret.flags & FordFlags.CANFD else True) if new_port else ret.radarUnavailable + ret.alphaLongitudinalAvailable = True if new_port else ret.radarUnavailable longitudinal_requested = bool(alpha_long and ret.alphaLongitudinalAvailable) if new_port else alpha_long or not ret.radarUnavailable if longitudinal_requested: ret.safetyConfigs[-1].safetyParam |= FordSafetyFlags.LONG_CONTROL.value @@ -139,11 +139,6 @@ class CarInterface(CarInterfaceBase): and ret.safetyConfigs[-1].safetyParam in (2, 3) ) if mach_e_extended: - if is_release: - # The native CANFD LONG profile is DEBUG-only; RELEASE stays stock. - ret.alphaLongitudinalAvailable = False - ret.openpilotLongitudinalControl = False - ret.safetyConfigs[-1].safetyParam = FordSafetyFlags.CANFD.value ret.safetyConfigs[-1].safetyParam |= FordSafetyFlags.MACH_E_EXTENDED.value if (candidate in GENERIC_CANFD_CARS and ret.flags & FordFlags.CANFD and not ret.flags & ~int(FordFlags.CANFD | FordFlags.HAS_BSM) and diff --git a/opendbc_repo/opendbc/car/ford/mache_lateral.py b/opendbc_repo/opendbc/car/ford/mache_lateral.py index 6159de95e0..1de89bd487 100644 --- a/opendbc_repo/opendbc/car/ford/mache_lateral.py +++ b/opendbc_repo/opendbc/car/ford/mache_lateral.py @@ -17,6 +17,7 @@ from dataclasses import replace import numpy as np from opendbc.car import structs +from opendbc.car.ford.aol import base_safety_param from opendbc.car.ford.values import CAR, FordFlags, FordSafetyFlags from opendbc.car.lateral import MAX_LATERAL_JERK from opendbc.car.ford.lateral_strategy import ( @@ -99,8 +100,6 @@ class MachELateralController(FordLateralController): def qualified(CP) -> bool: - if CP.alternativeExperience == 32 and len(CP.safetyConfigs) != 1: - return False if (CP.brand != "ford" or CP.carFingerprint != CAR.FORD_MUSTANG_MACH_E_MK1 or not CP.flags & FordFlags.CANFD or CP.flags & ~int(FordFlags.CANFD | FordFlags.HAS_BSM) or CP.passive or CP.dashcamOnly or CP.notCar or CP.alternativeExperience not in (0, 32) or @@ -111,7 +110,7 @@ def qualified(CP) -> bool: return False safety = CP.safetyConfigs[-1] return (safety.safetyModel == structs.CarParams.SafetyModel.ford and - safety.safetyParam == int(FordSafetyFlags.CANFD | FordSafetyFlags.MACH_E_EXTENDED | + base_safety_param(CP) == int(FordSafetyFlags.CANFD | FordSafetyFlags.MACH_E_EXTENDED | (FordSafetyFlags.LONG_CONTROL if CP.openpilotLongitudinalControl else 0))) diff --git a/opendbc_repo/opendbc/car/ford/new_port_curvature.py b/opendbc_repo/opendbc/car/ford/new_port_curvature.py index a7d6f7c6b0..725438db7c 100644 --- a/opendbc_repo/opendbc/car/ford/new_port_curvature.py +++ b/opendbc_repo/opendbc/car/ford/new_port_curvature.py @@ -7,6 +7,7 @@ from dataclasses import replace import math from opendbc.car import structs +from opendbc.car.ford.aol import base_safety_param from opendbc.car.ford.explorer_lateral import bounded_command as shared_bound from opendbc.car.ford.lateral_strategy import FordLateralController, FordLateralResult from opendbc.car.ford.values import CAR, FordFlags @@ -22,7 +23,7 @@ def qualified(cp): if (profile is None or cp.brand != "ford" or cp.passive or cp.dashcamOnly or cp.notCar or cp.alternativeExperience not in (0, 32) or not cp.pcmCruise or cp.steerControlType != structs.CarParams.SteerControlType.angle or - len(cp.safetyConfigs) not in (1, 2) or (cp.alternativeExperience == 32 and len(cp.safetyConfigs) != 1)): + len(cp.safetyConfigs) not in (1, 2)): return False flags, word = profile if (int(cp.flags) & ~int(FordFlags.HAS_BSM)) != flags: @@ -31,7 +32,7 @@ def qualified(cp): cp.safetyConfigs[0].safetyParam != 0): return False safety = cp.safetyConfigs[-1] - return safety.safetyModel == structs.CarParams.SafetyModel.ford and safety.safetyParam == word + int(cp.openpilotLongitudinalControl) + return safety.safetyModel == structs.CarParams.SafetyModel.ford and base_safety_param(cp) == word + int(cp.openpilotLongitudinalControl) class NewPortCurvatureController(FordLateralController): diff --git a/opendbc_repo/opendbc/car/ford/stock_cruise.py b/opendbc_repo/opendbc/car/ford/stock_cruise.py index f2456eff7b..a04ae321ff 100644 --- a/opendbc_repo/opendbc/car/ford/stock_cruise.py +++ b/opendbc_repo/opendbc/car/ford/stock_cruise.py @@ -1,6 +1,7 @@ """Ford physical cancel/resume switch context; no new cruise authority.""" from opendbc.car import structs +from opendbc.car.ford.aol import base_safety_param from opendbc.car.ford.values import CAR, FordFlags, FordSafetyFlags from opendbc.car.ford.classic_lateral import CLASSIC_EXTENDED_CARS from opendbc.car.ford.generic_canfd_lateral import qualified as generic_canfd_qualified @@ -30,8 +31,6 @@ def qualified(CP) -> bool: from opendbc.bluepilot_lateral.hosts.starpilot import qualified as angle_qualified if angle_qualified(CP): return not CP.openpilotLongitudinalControl - if CP.alternativeExperience == 32 and len(CP.safetyConfigs) != 1: - return False """Existing stock profiles with the paired physical-driver button contract.""" if (CP.brand != "ford" or CP.carFingerprint not in tuple(CAR) or CP.openpilotLongitudinalControl or CP.alternativeExperience not in (0, 32) or CP.passive or CP.dashcamOnly or CP.notCar or len(CP.safetyConfigs) not in (1, 2) or @@ -41,17 +40,17 @@ def qualified(CP) -> bool: CP.safetyConfigs[0].safetyParam != 0): return False safety = CP.safetyConfigs[-1] - if safety.safetyModel != structs.CarParams.SafetyModel.ford or safety.safetyParam not in (2, 8, 10, 12, 18, 32, 66): + if safety.safetyModel != structs.CarParams.SafetyModel.ford or base_safety_param(CP) not in (2, 8, 10, 12, 18, 32, 66): return False - if safety.safetyModel == structs.CarParams.SafetyModel.ford and safety.safetyParam == 32: + if safety.safetyModel == structs.CarParams.SafetyModel.ford and base_safety_param(CP) == 32: return CP.carFingerprint in CLASSIC_EXTENDED_CARS and not CP.flags & ~int(FordFlags.HAS_BSM) - if safety.safetyParam == 66: + if base_safety_param(CP) == 66: return generic_canfd_qualified(CP) expected = (int(FordSafetyFlags.CANFD) if CP.flags & FordFlags.CANFD else 0) expected |= int(FordSafetyFlags.NEW_PORT) if CP.flags & FordFlags.NEW_PORT else 0 expected |= int(FordSafetyFlags.LKA_STEERING) if CP.flags & FordFlags.LKA_STEERING else 0 - if safety.safetyParam == 18: + if base_safety_param(CP) == 18: if CP.carFingerprint != CAR.FORD_MUSTANG_MACH_E_MK1 or expected != int(FordSafetyFlags.CANFD): return False expected |= int(FordSafetyFlags.MACH_E_EXTENDED) - return safety.safetyParam == expected + return base_safety_param(CP) == expected diff --git a/opendbc_repo/opendbc/car/ford/tests/test_aol_startup.py b/opendbc_repo/opendbc/car/ford/tests/test_aol_startup.py index 06c02936e4..2911dc1e28 100644 --- a/opendbc_repo/opendbc/car/ford/tests/test_aol_startup.py +++ b/opendbc_repo/opendbc/car/ford/tests/test_aol_startup.py @@ -20,18 +20,24 @@ PROFILES = ( @pytest.mark.parametrize('identity,base_word', PROFILES) @pytest.mark.parametrize('alpha', (False, True)) -def test_unmarked_profiles_keep_actual_ordinary_controls(identity, base_word, alpha, monkeypatch): +@pytest.mark.parametrize('topology', ('automatic', 'manual', 'multi-panda', 'manual-multi')) +def test_unmarked_profiles_keep_actual_ordinary_controls(identity, base_word, alpha, topology, monkeypatch): from openpilot.common.params import Params from openpilot.common.prefix import OpenpilotPrefix from openpilot.selfdrive.controls.controlsd import Controls from openpilot.starpilot.lateral.tests.test_lane_runtime import feed - from opendbc.car.ford.tests.test_three_ports import params monkeypatch.setenv('REPLAY', '1') monkeypatch.setenv('AOL_REPLAY_RUNTIME', '0') - cp = params(identity, alpha, False) + offset = 4 if topology in ('multi-panda', 'manual-multi') else 0 + manual = topology in ('manual', 'manual-multi') + fp = gen_empty_fingerprint() + if not manual: + fp[offset][0x5A] = 8 + fp[offset + 2].update({0x3D6: 8, 0x186: 8}) + cp = CarInterface.get_params(identity, fp, [], alpha, True, False) assert cp.alternativeExperience == 0 - assert cp.safetyConfigs[0].safetyParam == base_word | int(alpha) + assert cp.safetyConfigs[-1].safetyParam == base_word | int(alpha) with OpenpilotPrefix(): saved = Params() saved.put_bool('AlwaysOnLateral', False, block=True) @@ -67,15 +73,17 @@ def run_startup(identity, base_word, alpha, release, topology, monkeypatch): if topology != "manual": fingerprint[offset][0x5A] = 8 fingerprint[offset + 2].update({0x3D6: 8, 0x186: 8}) - expected_long = alpha and not release - expected_word = base_word | int(expected_long) + expected_long = alpha + initial_word = base_word | int(expected_long) + expected_word = initial_word | (256 if topology == "manual" else 0) initial = CarInterface.get_params(identity, fingerprint, [], expected_long, release, False) initial.carVin = "0" * 17 initial.carFw = [] - admitted = topology == "automatic" + admitted = True expected = initial.to_dict() expected["alternativeExperience"] = 32 if admitted else 0 - assert initial.safetyConfigs[-1].safetyParam == expected_word + expected["safetyConfigs"][-1]["safetyParam"] = expected_word + assert initial.safetyConfigs[-1].safetyParam == initial_word assert initial.openpilotLongitudinalControl == expected_long assert not initial.passive and not initial.dashcamOnly with OpenpilotPrefix(): @@ -145,7 +153,7 @@ def run_startup(identity, base_word, alpha, release, topology, monkeypatch): controls.publish(command, lateral_log) ci.apply(command.as_reader(), 1_000_000_000) if admitted: - from openpilot.starpilot.aol.wire import SAFETY_SERVICE, SafetyState, encode_safety + from openpilot.starpilot.aol.wire import SAFETY_SERVICE, PandaSlot, SafetyState, encode_safety for tick, (acknowledged, stale) in enumerate(((False, False), (True, False), (True, True)), 1): now = 1_000_000_000 + tick * 10_000_000 @@ -160,8 +168,11 @@ def run_startup(identity, base_word, alpha, release, topology, monkeypatch): axis.aolAxisState.nativeAcknowledged = acknowledged native = messaging.new_message(SAFETY_SERVICE, 0, valid=True, logMonoTime=now) native.aolSafetyWire = encode_safety(SafetyState( - 1, True, now, now + 200_000_000, int(card.CP.safetyConfigs[0].safetyModel.raw), - card.CP.safetyConfigs[0].safetyParam, acknowledged, False, True, False, 'fixture-panda', 'ford-current')) + 1, True, now, now + 200_000_000, int(card.CP.safetyConfigs[-1].safetyModel.raw), + card.CP.safetyConfigs[-1].safetyParam, acknowledged, False, True, False, 'fixture-panda', 'ford-current', sourcePandaStatesMonoTime=now, + pandaInventory=tuple(PandaSlot(index, 'fixture-panda' if index == len(card.CP.safetyConfigs) - 1 else 'passive-panda', + int(config.safetyModel.raw), config.safetyParam, 32, False, False) + for index, config in enumerate(card.CP.safetyConfigs)))) controls.sm.update_msgs(now / 1e9, [axis.as_reader(), native.as_reader()]) active_command, _ = controls.state_control() assert active_command.enabled @@ -182,7 +193,7 @@ def test_actual_card_aol_publication_and_disabled_controls(identity, word, alpha @pytest.mark.parametrize("identity,word", PROFILES) @pytest.mark.parametrize("topology", ("manual", "multi-panda")) -def test_aol_denied_topology_preserves_actual_ordinary_startup(identity, word, topology, monkeypatch): +def test_aol_manual_and_multi_panda_keep_actual_ordinary_startup(identity, word, topology, monkeypatch): run_startup(identity, word, False, False, topology, monkeypatch) diff --git a/opendbc_repo/opendbc/car/ford/tests/test_blend_settings_startup.py b/opendbc_repo/opendbc/car/ford/tests/test_blend_settings_startup.py index 199f68f886..52c3392b71 100644 --- a/opendbc_repo/opendbc/car/ford/tests/test_blend_settings_startup.py +++ b/opendbc_repo/opendbc/car/ford/tests/test_blend_settings_startup.py @@ -17,7 +17,7 @@ def test_generic_actual_card_publication_reaches_blend_settings(identity, monkey from openpilot.cereal import messaging from openpilot.common.params import Params from openpilot.common.prefix import OpenpilotPrefix - from openpilot.selfdrive.car.card import Car, alpha_long_requested + from openpilot.selfdrive.car.card import Car from openpilot.selfdrive.controls.controlsd import Controls from openpilot.starpilot.controller_extensions import ManualTurnInputs from openpilot.starpilot.lateral.tests.test_lane_runtime import feed @@ -34,7 +34,7 @@ def test_generic_actual_card_publication_reaches_blend_settings(identity, monkey saved.put_bool('FordHumanTurnDetection', False, block=True) for key, value in (("FordCurvatureBlendLow", 0.25), ("FordCurvatureBlendHigh", 0.65), ("FordCurvatureLaneChangeFactor", 1.1)): saved.put(key, value, block=True) - cp = params(identity, alpha_long_requested(saved, is_release=release), release) + cp = params(identity, alpha, release) cp.carVin = '0' * 17 cp.carFw = [] expected = cp.to_dict() diff --git a/opendbc_repo/opendbc/car/ford/tests/test_generic_canfd.py b/opendbc_repo/opendbc/car/ford/tests/test_generic_canfd.py index 0aeaaacbd5..8b77995aff 100644 --- a/opendbc_repo/opendbc/car/ford/tests/test_generic_canfd.py +++ b/opendbc_repo/opendbc/car/ford/tests/test_generic_canfd.py @@ -10,14 +10,14 @@ from opendbc.car.ford.values import CAR, FordFlags @pytest.mark.parametrize("car", sorted(GENERIC_CANFD_CARS)) @pytest.mark.parametrize("alpha,release", ((False,False),(True,False),(False,True),(True,True))) -def test_actual_profile_and_release_long_denial(car, alpha, release): +def test_actual_profile_and_release_long_support(car, alpha, release): fp = gen_empty_fingerprint() fp[0][0x5A] = 8 fp[2].update({0x3D6:8,0x186:8}) cp = CarInterface.get_params(car, fp, [], alpha, release, False) - assert cp.alphaLongitudinalAvailable == (not release) - assert cp.openpilotLongitudinalControl == (alpha and not release) - assert cp.safetyConfigs[-1].safetyParam == (67 if alpha and not release else 66) + assert cp.alphaLongitudinalAvailable + assert cp.openpilotLongitudinalControl == alpha + assert cp.safetyConfigs[-1].safetyParam == (67 if alpha else 66) assert cp.steerActuatorDelay == pytest.approx(.22) assert qualified(cp) @@ -212,8 +212,8 @@ def test_mache_camera_source_preserves_exact_factory_words(alpha, release, bsm): if bsm: fp[0].update({0x3A6: 8, 0x3A7: 8}) cp = CarInterface.get_params(CAR.FORD_MUSTANG_MACH_E_MK1, fp, [], alpha, release, False) - assert cp.safetyConfigs[-1].safetyParam == (19 if alpha and not release else 18) - assert cp.openpilotLongitudinalControl == (alpha and not release) + assert cp.safetyConfigs[-1].safetyParam == (19 if alpha else 18) + assert cp.openpilotLongitudinalControl == alpha assert not cp.radarUnavailable radar = RadarInterface(cp) assert radar.rcp.bus == 2 and radar.generic_canfd_lead diff --git a/opendbc_repo/opendbc/car/ford/tests/test_mach_e_profile.py b/opendbc_repo/opendbc/car/ford/tests/test_mach_e_profile.py index 88f9be2a23..6f16abfa0c 100644 --- a/opendbc_repo/opendbc/car/ford/tests/test_mach_e_profile.py +++ b/opendbc_repo/opendbc/car/ford/tests/test_mach_e_profile.py @@ -14,15 +14,15 @@ def fingerprint(offset=0, secoc=False): class TestMachEProfile(unittest.TestCase): - def test_actual_params_stock_debug_long_and_release_stock(self): - for alpha, release, word in ((False, False, 18), (True, False, 19), (False, True, 18), (True, True, 18)): + def test_actual_params_stock_debug_long_and_release_long(self): + for alpha, release, word in ((False, False, 18), (True, False, 19), (False, True, 18), (True, True, 19)): with self.subTest(alpha=alpha, release=release): cp = CarInterface.get_params(CAR.FORD_MUSTANG_MACH_E_MK1, fingerprint(), [], alpha, release, False) self.assertEqual(cp.safetyConfigs[-1].safetyParam, word) self.assertEqual(cp.openpilotLongitudinalControl, word == 19) self.assertEqual(cp.alternativeExperience, 0) if release: - self.assertFalse(cp.alphaLongitudinalAvailable) + self.assertTrue(cp.alphaLongitudinalAvailable) def test_secoc_unrelated_flags_and_ae_do_not_select_extension(self): cp = CarInterface.get_params(CAR.FORD_MUSTANG_MACH_E_MK1, fingerprint(secoc=True), [], False, False, False) diff --git a/opendbc_repo/opendbc/car/ford/tests/test_new_port_curvature.py b/opendbc_repo/opendbc/car/ford/tests/test_new_port_curvature.py index d4ddbb6859..e8ad2d290a 100644 --- a/opendbc_repo/opendbc/car/ford/tests/test_new_port_curvature.py +++ b/opendbc_repo/opendbc/car/ford/tests/test_new_port_curvature.py @@ -28,7 +28,7 @@ class TestNewPortCurvature(unittest.TestCase): for identity in PROFILES: for alpha, release in ((False, False), (True, False), (False, True), (True, True)): cp = factory(identity, alpha, release) - expected_long = bool(alpha and (identity == CAR.FORD_EDGE_MK2 or not release)) + expected_long = bool(alpha) self.assertTrue(cp.pcmCruise) self.assertEqual(cp.openpilotLongitudinalControl, expected_long) self.assertEqual(cp.safetyConfigs[-1].safetyParam, (8 if identity == CAR.FORD_EDGE_MK2 else 10) + int(expected_long)) @@ -156,6 +156,7 @@ class TestNewPortCurvature(unittest.TestCase): command, controller_log = controls.state_control() self.assertTrue(command.enabled) control_events = [] + def capture_control(service, event, collector=control_events): if service == "carControl": collector.append(messaging.log_from_bytes(event.to_bytes())) diff --git a/opendbc_repo/opendbc/car/ford/tests/test_production_extensions.py b/opendbc_repo/opendbc/car/ford/tests/test_production_extensions.py index 7e36917a95..b1fcfeaf0b 100644 --- a/opendbc_repo/opendbc/car/ford/tests/test_production_extensions.py +++ b/opendbc_repo/opendbc/car/ford/tests/test_production_extensions.py @@ -20,7 +20,7 @@ def test_actual_card_publication_reaches_ford_extensions(identity, alpha, releas from openpilot.cereal import messaging from openpilot.common.params import Params from openpilot.common.prefix import OpenpilotPrefix - from openpilot.selfdrive.car.card import Car, alpha_long_requested + from openpilot.selfdrive.car.card import Car from openpilot.selfdrive.controls.controlsd import Controls from openpilot.starpilot.controller_extensions import ManualTurnInputs from openpilot.starpilot.lateral.tests.test_lane_runtime import feed @@ -36,7 +36,7 @@ def test_actual_card_publication_reaches_ford_extensions(identity, alpha, releas saved.put_bool('IsReleaseBranch', release, block=True) saved.put_bool('DisableOpenpilotLongitudinal', disable, block=True) saved.put_bool('FordHumanTurnDetection', False, block=True) - cp = params(identity, alpha_long_requested(saved, is_release=release), release) + cp = params(identity, alpha, release) cp.carVin = '0' * 17 cp.carFw = [] expected = cp.to_dict() diff --git a/opendbc_repo/opendbc/car/ford/tests/test_three_ports.py b/opendbc_repo/opendbc/car/ford/tests/test_three_ports.py index ff9305ce1c..9bcfc39b07 100644 --- a/opendbc_repo/opendbc/car/ford/tests/test_three_ports.py +++ b/opendbc_repo/opendbc/car/ford/tests/test_three_ports.py @@ -62,7 +62,7 @@ class TestFordThreePorts(unittest.TestCase): self.assertTrue(requested.openpilotLongitudinalControl) self.assertTrue(requested.safetyConfigs[-1].safetyParam & FordSafetyFlags.LONG_CONTROL) if candidate == CAR.FORD_MONDEO_MK5: - self.assertFalse(params(candidate, alpha=True, release=True).openpilotLongitudinalControl) + self.assertTrue(params(candidate, alpha=True, release=True).openpilotLongitudinalControl) self.assertAlmostEqual(params(CAR.FORD_MUSTANG_MACH_E_MK1).steerActuatorDelay, 0.22, places=7) self.assertFalse(params(CAR.FORD_BRONCO_SPORT_MK1).openpilotLongitudinalControl) self.assertTrue(params(CAR.FORD_BRONCO_SPORT_MK1, alpha=True).openpilotLongitudinalControl) diff --git a/opendbc_repo/opendbc/car/ford/values.py b/opendbc_repo/opendbc/car/ford/values.py index 9b18c74b25..60d1f0b447 100644 --- a/opendbc_repo/opendbc/car/ford/values.py +++ b/opendbc_repo/opendbc/car/ford/values.py @@ -48,6 +48,7 @@ class FordSafetyFlags(IntFlag): MACH_E_EXTENDED = 16 CLASSIC_EXTENDED = 32 EXPLORER_EXTENDED = CLASSIC_EXTENDED # Existing public profile spelling. + MANUAL = 256 BLUEPILOT_ANGLE = 128 GENERIC_CANFD_EXTENDED = 64 @@ -328,12 +329,12 @@ FW_QUERY_CONFIG = FwQueryConfig( whitelist_ecus=[Ecu.abs, Ecu.debug, Ecu.engine, Ecu.eps, Ecu.fwdCamera, Ecu.fwdRadar, Ecu.shiftByWire], logging=True, ), - Request( + *[Request( [StdQueries.TESTER_PRESENT_REQUEST, StdQueries.MANUFACTURER_SOFTWARE_VERSION_REQUEST], [StdQueries.TESTER_PRESENT_RESPONSE, StdQueries.MANUFACTURER_SOFTWARE_VERSION_RESPONSE], whitelist_ecus=[Ecu.abs, Ecu.debug, Ecu.engine, Ecu.eps, Ecu.fwdCamera, Ecu.fwdRadar, Ecu.shiftByWire], - bus=0, - ), + bus=bus, + ) for bus in (0, 4)], *[Request( [StdQueries.TESTER_PRESENT_REQUEST, ford_asbuilt_block_request(block_id)], [StdQueries.TESTER_PRESENT_RESPONSE, ford_asbuilt_block_response(block_id)], diff --git a/opendbc_repo/opendbc/safety/modes/ford.h b/opendbc_repo/opendbc/safety/modes/ford.h index 9dea5972b8..9a2b1604ce 100644 --- a/opendbc_repo/opendbc/safety/modes/ford.h +++ b/opendbc_repo/opendbc/safety/modes/ford.h @@ -501,7 +501,11 @@ static void ford_aol_bind_tx(safety_config *ret, CanMsg workspace[7]) { } } -static safety_config ford_init(uint16_t param) { +static safety_config ford_init(uint16_t configured_param) { + const uint16_t param = configured_param & ~256U; + const bool manual = (configured_param & 256U) != 0U; + // Manual variants are only defined for the exact registered Ford profiles. + const bool manual_valid = !manual || ford_aol_param_valid(configured_param); // warning: quality flags are not yet checked in openpilot's CAN parser, // this may be the cause of blocked messages // Shared entries preserve all current checksum/counter/quality contracts. @@ -534,13 +538,11 @@ static safety_config ford_init(uint16_t param) { {FORD_Lane_Assist_Data1, 0, 8, .check_relay = true}, \ {FORD_IPMA_Data, 0, 8, .check_relay = true}, \ -#ifdef ALLOW_DEBUG static const CanMsg FORD_CANFD_LONG_TX_MSGS[] = { FORD_COMMON_TX_MSGS {FORD_ACCDATA, 0, 8, .check_relay = true}, {FORD_LateralMotionControl2, 0, 8, .check_relay = true}, }; -#endif static const CanMsg FORD_CANFD_STOCK_TX_MSGS[] = { FORD_COMMON_TX_MSGS @@ -565,9 +567,7 @@ static safety_config ford_init(uint16_t param) { const uint16_t FORD_PARAM_GENERIC_CANFD_EXTENDED = 64U; const bool generic_canfd_namespace = GET_FLAG(param, FORD_PARAM_GENERIC_CANFD_EXTENDED); ford_generic_canfd_extended = (param == 66U) && (((unsigned int)alternative_experience == 0U) || (((unsigned int)alternative_experience == 32U) && ford_aol_param_valid(param))); -#ifdef ALLOW_DEBUG ford_generic_canfd_extended |= (param == 67U) && (((unsigned int)alternative_experience == 0U) || (((unsigned int)alternative_experience == 32U) && ford_aol_param_valid(param))); -#endif ford_generic_canfd_announced = false; const bool explorer_namespace = GET_FLAG(param, FORD_PARAM_EXPLORER_EXTENDED); ford_explorer_extended = ((param == 32U) || (param == 33U)) && @@ -575,9 +575,7 @@ static safety_config ford_init(uint16_t param) { ford_explorer_announced = false; const bool mach_e_namespace = GET_FLAG(param, FORD_PARAM_MACH_E_EXTENDED); ford_mach_e_extended = (param == 18U) && (((unsigned int)alternative_experience == 0U) || (((unsigned int)alternative_experience == 32U) && ford_aol_param_valid(param))); -#ifdef ALLOW_DEBUG ford_mach_e_extended |= (param == 19U) && (((unsigned int)alternative_experience == 0U) || (((unsigned int)alternative_experience == 32U) && ford_aol_param_valid(param))); -#endif const bool bp_config_valid = ford_bp_configure(param); ford_stock_switch = ((((unsigned int)alternative_experience == 0U) || (((unsigned int)alternative_experience == 32U) && ford_aol_param_valid(param))) && ((param == 2U) || (param == 8U) || (param == 10U) || (param == 12U) || @@ -605,12 +603,13 @@ static safety_config ford_init(uint16_t param) { safety_config ret; if (ford_canfd) { ret = BUILD_SAFETY_CFG(ford_rx_checks, FORD_CANFD_STOCK_TX_MSGS); + bool long_profile = (param == 11U) || (param == 19U) || (param == 67U); #ifdef ALLOW_DEBUG - const uint16_t FORD_PARAM_LONGITUDINAL = 1; - if (GET_FLAG(param, FORD_PARAM_LONGITUDINAL)) { + long_profile |= GET_FLAG(param, 1U); +#endif + if (long_profile) { ret = BUILD_SAFETY_CFG(ford_rx_checks, FORD_CANFD_LONG_TX_MSGS); } -#endif } else { ret = ford_new_port ? BUILD_SAFETY_CFG(ford_rx_checks, FORD_STOCK_TX_MSGS) : BUILD_SAFETY_CFG(ford_rx_checks, FORD_LONG_TX_MSGS); @@ -632,13 +631,13 @@ static safety_config ford_init(uint16_t param) { SET_RX_CHECKS(ford_stock_rx_checks, ret); } } - if (!bp_config_valid || (mach_e_namespace && !ford_mach_e_extended) || + if (!manual_valid || !bp_config_valid || (mach_e_namespace && !ford_mach_e_extended) || (explorer_namespace && !ford_explorer_extended) || (generic_canfd_namespace && !ford_generic_canfd_extended)) { ret.tx_msgs = NULL; ret.tx_msgs_len = 0; } - ford_aol_configure(param); + ford_aol_configure(configured_param); if (ford_aol_enabled) { static const RxCheck ford_aol_default_rx[] = { FORD_COMMON_RX_CHECKS @@ -687,6 +686,10 @@ static safety_config ford_init(uint16_t param) { for (int i = 0; i < 10; i++) { if (i < selected_len) { ford_aol_rx_workspace[i] = selected_rx[i]; + const unsigned int addr = ford_aol_rx_workspace[i].msg[0].addr; + if (manual && ((addr == 0x176U) || (addr == 0x230U) || (addr == 0x5AU))) { + ford_aol_rx_workspace[i].msg[0].addr = 0x3C3U; + } } else { ford_aol_rx_workspace[i] = (RxCheck){0}; } @@ -698,9 +701,7 @@ static safety_config ford_init(uint16_t param) { _Static_assert(sizeof(FORD_CANFD_STOCK_TX_MSGS) <= sizeof(ford_aol_tx_msgs), "Ford CAN FD stock TX capacity"); _Static_assert(sizeof(FORD_STOCK_TX_MSGS) <= sizeof(ford_aol_tx_msgs), "Ford stock TX capacity"); _Static_assert(sizeof(FORD_LONG_TX_MSGS) <= sizeof(ford_aol_tx_msgs), "Ford long TX capacity"); -#ifdef ALLOW_DEBUG _Static_assert(sizeof(FORD_CANFD_LONG_TX_MSGS) <= sizeof(ford_aol_tx_msgs), "Ford CAN FD long TX capacity"); -#endif ford_aol_bind_tx(&ret, ford_aol_tx_msgs); } return ret; diff --git a/opendbc_repo/opendbc/safety/modes/ford_aol.h b/opendbc_repo/opendbc/safety/modes/ford_aol.h index 4e7bd9c6be..c8bedb8b0e 100644 --- a/opendbc_repo/opendbc/safety/modes/ford_aol.h +++ b/opendbc_repo/opendbc/safety/modes/ford_aol.h @@ -1,5 +1,6 @@ #pragma once +static bool ford_aol_manual = false; static bool ford_aol_enabled = false; static bool ford_aol_token = false; static bool ford_aol_main_available = false; @@ -151,7 +152,9 @@ static void ford_aol_rx(const CANPacket_t *msg) { ford_aol_lka_ready = ((ford_aol_param & 0xFFFEU) == 12U) ? ((((msg->data[0] >> 4U) & 3U) == 3U) && ((msg->data[0] & 0x40U) == 0U)) : ((state == 1U) || (state == 2U) || (state == 3U)); - } else { + } else if (ford_aol_manual && msg_matches(msg, 0x3C3U, 0U, 8U)) { + ford_aol_drive = !GET_BIT(msg, 9U); + } else if (!ford_aol_manual) { const uint16_t base = ford_aol_param & 0xFFFEU; const uint16_t gear_addr = (base == 8U) ? 0x230U : ((base == 10U) ? 0x5AU : 0x176U); if (msg_matches(msg, gear_addr, 0U, 8U)) { @@ -159,6 +162,8 @@ static void ford_aol_rx(const CANPacket_t *msg) { ((base == 10U) ? (msg->data[6] & 15U) : (msg->data[3] & 15U)); ford_aol_drive = (gear == 3U) || (gear == 4U) || (gear == 5U); } + } else { + // Manual profiles consume only the dedicated reverse lamp source. } } } @@ -177,15 +182,13 @@ static void ford_aol_full_engagement(bool cruise_rising) { static bool ford_aol_param_valid(uint16_t param) { bool result = false; - switch (param) { + switch (param & ~256U) { case 8U: case 9U: case 10U: case 12U: case 13U: case 18U: case 32U: case 33U: case 66U: result = true; break; -#ifdef ALLOW_DEBUG case 11U: case 19U: case 67U: result = true; break; -#endif default: break; } @@ -195,7 +198,8 @@ static bool ford_aol_param_valid(uint16_t param) { static void ford_aol_configure(uint16_t param) { ford_aol_reset(); ford_aol_enabled = ford_aol_param_valid(param) && ((unsigned int)alternative_experience == 32U); - ford_aol_param = param; + ford_aol_manual = (param & 256U) != 0U; + ford_aol_param = param & ~256U; if (ford_aol_enabled) { static const AolSafetyPolicy policy = { .reset = ford_aol_reset, diff --git a/opendbc_repo/opendbc/safety/tests/ford_release_contract.py b/opendbc_repo/opendbc/safety/tests/ford_release_contract.py index cdc840e75e..de269a24d7 100644 --- a/opendbc_repo/opendbc/safety/tests/ford_release_contract.py +++ b/opendbc_repo/opendbc/safety/tests/ford_release_contract.py @@ -8,11 +8,11 @@ from opendbc.safety.tests.libsafety import libsafety_py libsafety_py.ffi.cdef('extern safety_config current_safety_config;') STOCK_CANFD_TX = frozenset(((0x83, 0, 8), (0x83, 2, 8), (0x18A, 0, 8), (0x3CA, 0, 8), (0x3D8, 0, 8), (0x3D6, 0, 8))) -RELEASE_LEDGER = {3: (6, STOCK_CANFD_TX), 11: (6, STOCK_CANFD_TX), 19: (6, frozenset()), 67: (6, frozenset())} +RELEASE_LEDGER = {3: (6, STOCK_CANFD_TX)} def assert_release_denial(native, word): - """Prove absent AOL/LONG, preserving the existing stock fallback of3/11. + """Prove absent AOL/LONG, preserving the existing stock fallback of legacy parameter3. A physical PCM edge can still set the ordinary boolean even with an empty TX table. No assertion equates that boolean or hook return code with authority. diff --git a/opendbc_repo/opendbc/safety/tests/test_ford_aol.py b/opendbc_repo/opendbc/safety/tests/test_ford_aol.py index 4f73b890d5..6a7fd73282 100644 --- a/opendbc_repo/opendbc/safety/tests/test_ford_aol.py +++ b/opendbc_repo/opendbc/safety/tests/test_ford_aol.py @@ -16,6 +16,17 @@ class TestFordAolAdmission(unittest.TestCase): self.safety.set_alternative_experience(0) self.safety.set_safety_hooks(structs.CarParams.SafetyModel.noOutput, 0) + def test_angle_profiles_reject_malformed_lateral_frames_without_healthy_sources(self): + for word in (128, 129, 130, 131): + with self.subTest(word=word): + self.safety.init_tests() + self.safety.set_alternative_experience(0) + self.assertEqual(self.safety.set_safety_hooks(structs.CarParams.SafetyModel.ford, word), 0) + self.safety.set_controls_allowed(False) + for address in (0x3CA, 0x3D3 if word & 2 else 0x3D6): + self.assertFalse(self.safety.safety_tx_hook(libsafety_py.make_CANPacket(address, 0, bytes(8)))) + self.assertFalse(self.safety.get_controls_allowed()) + def test_no_frames_no_lateral_or_longitudinal_authority(self): for word in (8, 9, 10, 11, 12, 13, 18, 19, 32, 33, 66, 67): for ae in (0, 32, 33): @@ -31,7 +42,8 @@ class TestFordAolAdmission(unittest.TestCase): class TestFordAolDriverIntent(unittest.TestCase): - PROFILES = (32, 8, 10, 12, 18, 66) + BASE_PROFILES = (8, 9, 10, 11, 12, 13, 18, 19, 32, 33, 66, 67) + PROFILES = BASE_PROFILES + tuple(word | 256 for word in BASE_PROFILES) def setUp(self): from opendbc.can import CANPacker @@ -56,8 +68,12 @@ class TestFordAolDriverIntent(unittest.TestCase): self.assertTrue(self.safety.safety_rx_hook(libsafety_py.make_CANPacket(address, bus, data))) def pump(self, count, *, main=3, tja=False, gear=3, eps=0, mask=0, heartbeat=True, speed=10., curvature=0.): - gear_name, gear_signal = ('TransGearData', 'GearLvrPos_D_Actl') if self.word == 8 else ( - ('Gear_Shift_by_Wire_FD1', 'TrnRng_D_RqGsm') if self.word == 10 else ('PowertrainData_10', 'TrnRng_D_Rq')) + base = self.word & ~257 + gear_name, gear_signal = ('TransGearData', 'GearLvrPos_D_Actl') if base == 8 else ( + ('Gear_Shift_by_Wire_FD1', 'TrnRng_D_RqGsm') if base == 10 else ('PowertrainData_10', 'TrnRng_D_Rq')) + if self.word & 256: + gear_name, gear_signal = 'BCM_Lamp_Stat_FD1', 'RvrseLghtOn_B_Stat' + gear = int(gear == 1) for _ in range(count): self.tick += 1 self.safety.set_timer(1_000_000 + self.tick * 10_000) @@ -76,7 +92,7 @@ class TestFordAolDriverIntent(unittest.TestCase): for name, hz, fields in values: if self.tick * hz // 100 != (self.tick - 1) * hz // 100: self.rx(name, fields) - if (self.word & ~1) in (10, 12, 18, 66) and self.tick * 30 // 100 != (self.tick - 1) * 30 // 100: + if base in (10, 12, 18, 66) and self.tick * 30 // 100 != (self.tick - 1) * 30 // 100: self.rx('Lane_Assist_Data3_FD1', {'LatCtlSte_D_Stat': 1, 'LaActAvail_D_Actl': 3, 'LaActDeny_B_Actl': 0}) self.safety.aol_set_host_request(mask) self.safety.aol_get_permission_mask() diff --git a/opendbc_repo/opendbc/safety/tests/test_ford_configuration_parity.py b/opendbc_repo/opendbc/safety/tests/test_ford_configuration_parity.py new file mode 100644 index 0000000000..4db62d3a85 --- /dev/null +++ b/opendbc_repo/opendbc/safety/tests/test_ford_configuration_parity.py @@ -0,0 +1,103 @@ +"""Exact manual profiles consume a fresh physical reverse signal on local bus0.""" +import pytest +from opendbc.car import structs +from opendbc.safety.tests.libsafety import libsafety_py +from opendbc.safety.tests.test_ford_full_engagement import ford_native as ford_native, select +from opendbc.safety.tests.test_ford_alpha_aol import tx + +WORDS = (8, 9, 10, 11, 12, 13, 18, 19, 32, 33, 66, 67) + + +@pytest.mark.parametrize('base', WORDS) +def test_manual_activation_brake_reverse_fault_recovery_and_long_bounds(ford_native, base): + n = ford_native + select(n, base | 256) + n.pump(40, main=3) + n.pump(10, main=4, mask=3) + assert n.safety.aol_get_permission_mask() == (3 if base & 1 else 1) + assert tx(n, active=True) == bool(base & 1) + n.rx('EngBrakeData', {'BpedDrvAppl_D_Actl': 2, 'CcStat_D_Actl': 3}) + assert n.safety.aol_get_permission_mask() == 1 + assert not tx(n, active=True) + n.pump(20, gear=1, mask=3) + assert n.safety.aol_get_permission_mask() == 0 + n.pump(20, gear=3, mask=3) + assert n.safety.aol_get_permission_mask() == 1 + n.pump(20, eps=1, mask=3) + assert n.safety.aol_get_permission_mask() == 0 + n.pump(20, eps=0, mask=3) + assert n.safety.aol_get_permission_mask() == 1 + n.pump(20, eps=2, mask=3) + assert n.safety.aol_get_permission_mask() == 0 + n.pump(20, eps=0, mask=3) + assert n.safety.aol_get_permission_mask() == 0 + assert not n.safety.get_controls_allowed() and not tx(n, active=True) + n.pump(20, main=0, mask=3) + assert n.safety.aol_get_permission_mask() == 0 + + +@pytest.mark.parametrize('base', WORDS) +@pytest.mark.parametrize('failure', ['missing', 'wrong-bus', 'wrong-length', 'automatic-gear']) +def test_manual_reverse_source_cannot_be_substituted(ford_native, base, failure): + n = ford_native + select(n, base | 256) + real_rx = n.rx + + def filtered(name, values): + if name != 'BCM_Lamp_Stat_FD1': + return real_rx(name, values) + if failure == 'missing': + return None + if failure == 'automatic-gear': + return real_rx('PowertrainData_10', {'TrnRng_D_Rq': 3}) + addr, data, _ = n.packer.make_can_msg(name, 0, values) + n.safety.safety_rx_hook(libsafety_py.make_CANPacket(addr, 1 if failure == 'wrong-bus' else 0, data if failure == 'wrong-bus' else data[:-1])) + n.rx = filtered + n.pump(40, main=3) + n.pump(10, main=4, mask=3) + assert not n.safety.safety_config_valid() + assert n.safety.aol_get_permission_mask() == 0 + assert not tx(n, active=True) + + +@pytest.mark.parametrize('base', WORDS) +def test_manual_reverse_source_expiry_withdraws_axes(ford_native, base): + n = ford_native + select(n, base | 256) + n.pump(40, main=3) + n.pump(10, main=4, mask=3) + assert n.safety.aol_get_permission_mask() & 1 + original = n.rx + n.rx = lambda name, fields: None if name == 'BCM_Lamp_Stat_FD1' else original(name, fields) + for _ in range(120): + n.pump(1, main=4, mask=3) + n.safety.safety_tick() + assert not n.safety.safety_config_valid() + assert n.safety.aol_get_permission_mask() == 0 + assert not tx(n, active=True) + + +@pytest.mark.parametrize('base', WORDS) +def test_manual_unmarked_profile_preserves_ordinary_physical_long_and_brake(ford_native, base): + n = ford_native + select(n, base, ae=0) + n.word |= 256 # Emit real manual sources; ordinary safety keeps its original namespace. + n.pump(40, main=3) + assert not n.safety.get_controls_allowed() + assert n.safety.aol_get_permission_mask() == 0 + assert not tx(n, active=True) + n.pump(10, main=4) + assert n.safety.get_controls_allowed() and n.safety.safety_config_valid() + assert n.safety.aol_get_permission_mask() == 0 + assert tx(n, active=True) == bool(base & 1) + n.rx('EngBrakeData', {'BpedDrvAppl_D_Actl': 2, 'CcStat_D_Actl': 3}) + assert not n.safety.get_controls_allowed() and not tx(n, active=True) + + +def test_release_keeps_unregistered_legacy_canfd_long_parameter_denied(ford_native): + n = ford_native + if n.safety.set_safety_hooks(structs.CarParams.SafetyModel.allOutput, 0) == 0: + pytest.skip('Legacy parameter3 remains DEBUG-only; RELEASE exercises the denial') + from opendbc.safety.tests.ford_release_contract import assert_release_denial + select(n, 3) + assert_release_denial(n, 3) diff --git a/opendbc_repo/opendbc/safety/tests/test_ford_full_engagement.py b/opendbc_repo/opendbc/safety/tests/test_ford_full_engagement.py index ffa3f060e4..28ce7e3179 100644 --- a/opendbc_repo/opendbc/safety/tests/test_ford_full_engagement.py +++ b/opendbc_repo/opendbc/safety/tests/test_ford_full_engagement.py @@ -3,7 +3,6 @@ import pytest from opendbc.car import structs -from opendbc.safety.tests.ford_release_contract import assert_release_denial from opendbc.safety.tests.test_ford_aol import TestFordAolDriverIntent as _FordNative WORDS = (8, 9, 10, 11, 12, 13, 18, 19, 32, 33, 66, 67) @@ -21,14 +20,10 @@ def ford_native(): def select(native, word, *, ae=32): safety = native.safety - debug = safety.set_safety_hooks(structs.CarParams.SafetyModel.allOutput, 0) == 0 safety.init_tests() safety.set_alternative_experience(ae) result = safety.set_safety_hooks(structs.CarParams.SafetyModel.ford, word) assert result == 0 - if not debug and word in (11, 19, 67): - assert_release_denial(native, word) - pytest.skip('RELEASE configuration/physical RX/AOL and TX denial proved; positive vector requires DEBUG') native.word, native.tick = word & ~1, 0 safety.set_aol_test_heartbeat(True) diff --git a/opendbc_repo/opendbc/safety/tests/test_ford_generic_canfd_extended.py b/opendbc_repo/opendbc/safety/tests/test_ford_generic_canfd_extended.py index 46cee64591..43e136aaf3 100644 --- a/opendbc_repo/opendbc/safety/tests/test_ford_generic_canfd_extended.py +++ b/opendbc_repo/opendbc/safety/tests/test_ford_generic_canfd_extended.py @@ -41,14 +41,14 @@ class TestFordGenericCanfdExtended(unittest.TestCase): self._set_prev_desired_angle(0.) self.safety.set_controls_allowed(True) - def test_exact_namespace_and_release_long_denial(self): + def test_exact_namespace_and_release_long_support(self): for word in (66,67): self.select(word) - admitted = word == 66 or self.debug + admitted = True self.assertEqual(admitted, self.announce()) self.prepare() self.assertEqual(admitted, self._tx(self._lat_ctl_msg(True,0,0,0,.0002))) - self.assertEqual(word == 67 and self.debug, + self.assertEqual(word == 67, self._tx(self._acc_command_msg(self.INACTIVE_GAS,self.INACTIVE_ACCEL,False))) for word, ae in ((64,0),(65,0),(68,0),(70,0),(74,0),(82,0),(98,0),(194,0),(66,33),(67,33)): self.select(word,ae) @@ -56,7 +56,7 @@ class TestFordGenericCanfdExtended(unittest.TestCase): self.assertFalse(self._tx(self._lat_ctl_msg(False,0,0,0,0))) for word in (66, 67): self.select(word, 32) - self.assertEqual(word == 66 or self.debug, self.announce()) + self.assertTrue(self.announce()) self.assertFalse(self._tx(self._lat_ctl_msg(True, 0, 0, .0002, 0))) def test_announcement_path_and_inactive_contract(self): diff --git a/opendbc_repo/opendbc/safety/tests/test_ford_mach_e_extended.py b/opendbc_repo/opendbc/safety/tests/test_ford_mach_e_extended.py index 55cbf3176f..e64326d922 100644 --- a/opendbc_repo/opendbc/safety/tests/test_ford_mach_e_extended.py +++ b/opendbc_repo/opendbc/safety/tests/test_ford_mach_e_extended.py @@ -169,27 +169,11 @@ class TestFordMachEDebugLongExtended(TestFordMachEExtended): REQUIRES_DEBUG = True -class TestFordMachELongReleaseDenied(unittest.TestCase): +class TestFordMachELongReleaseExtended(TestFordMachEExtended): SAFETY_PARAM = 19 REQUIRES_DEBUG = False - def setUp(self): - safety = libsafety_py.libsafety - debug = safety.set_safety_hooks(CarParams.SafetyModel.allOutput, 0) == 0 - safety.set_safety_hooks(CarParams.SafetyModel.noOutput, 0) - safety.set_alternative_experience(0) - if self.REQUIRES_DEBUG is not None and debug != self.REQUIRES_DEBUG: - self.skipTest("This case requires " + ("DEBUG" if self.REQUIRES_DEBUG else "RELEASE") + " safety") - self._message_fixture = ford.TestFordCANFDStockSafety() - self._message_fixture.SAFETY_PARAM = self.SAFETY_PARAM - self._message_fixture.setUp() - def __getattr__(self, name): - fixture = self.__dict__.get("_message_fixture") - if fixture is None: - raise AttributeError(name) - return getattr(fixture, name) - - def test_long_profile_no_transmit_authority_in_release(self): - self.safety.set_controls_allowed(True) - self.assertFalse(self._tx(self._lat_ctl_msg(True, 0.0, 0.0, 0.0, 0.0))) +# Compatibility for existing CI selector names: this runs the promoted profile's +# complete positive RELEASE contract, including the unchanged actuation bounds. +TestFordMachELongReleaseDenied = TestFordMachELongReleaseExtended diff --git a/openpilot/selfdrive/car/card.py b/openpilot/selfdrive/car/card.py index e82be2a560..fe4c6ae376 100644 --- a/openpilot/selfdrive/car/card.py +++ b/openpilot/selfdrive/car/card.py @@ -295,6 +295,8 @@ class Car: cp, long_cp, enabled=eligible, is_release=is_release) self.ioniq6_long_selected = self.ioniq6_long_pending cp = startup_preferences.prepare(cp, fingerprints=fingerprints) + from openpilot.starpilot.car.ford.longitudinal_preferences import prepare_release_long as prepare_ford_release_long + cp = prepare_ford_release_long(cp, self.params, is_release=is_release, enabled=openpilot_enabled_toggle) return self.vehicle_startup.prepare(cp, interfaces[candidate], self.can_callbacks, requested=alpha_long_allowed and not is_release and openpilot_enabled_toggle, admission=self.startup_diagnostic_admission) @@ -387,8 +389,12 @@ class Car: self.aol_sequence = 0 self.aol_process_fault_context = AolProcessFaultContext() if self.aol_replay else None if self.aol_qualified: - config = (volkswagen_aol_safety_config(self.CP) if self.CP.brand == 'volkswagen' else - (hyundai_aol_safety_config(self.CP) if self.CP.brand == 'hyundai' else self.CP.safetyConfigs[0])) + if self.CP.brand == 'ford': + from opendbc.car.ford.aol import aol_safety_config as ford_aol_safety_config + config = ford_aol_safety_config(self.CP) + else: + config = (volkswagen_aol_safety_config(self.CP) if self.CP.brand == 'volkswagen' else + (hyundai_aol_safety_config(self.CP) if self.CP.brand == 'hyundai' else self.CP.safetyConfigs[0])) if config is None: raise RuntimeError('AOL requires the exact vehicle safety configuration') config.safetyParam |= aol_policy.safety_param_addition diff --git a/openpilot/selfdrive/car/tests/test_registered_ford_discovery.py b/openpilot/selfdrive/car/tests/test_registered_ford_discovery.py index a1df978131..d258c83d07 100644 --- a/openpilot/selfdrive/car/tests/test_registered_ford_discovery.py +++ b/openpilot/selfdrive/car/tests/test_registered_ford_discovery.py @@ -19,14 +19,15 @@ VIN = '3FMTK1SS0MMA00001' class FordEcus: """Transport only: the real ISO-TP layer assembles VIN and firmware replies.""" - def __init__(self, *, firmware=None, vin=VIN, background=None): + def __init__(self, *, firmware=None, vin=VIN, background=None, offset=0): + self.offset = offset self.clock = 100. self.vin = vin self.versions = firmware if firmware is not None else {(0x706, b'\x22\xf1\x88'): b'RJ6T-14H102-BAE', (0x730, b'\x22\xf1\x88'): b'LJ9C-14D003-GA', (0x760, b'\x22\xf1\x88'): b'LK9C-2D053-CN', (0x7E0, b'\x22\xf1\x88'): b'MJ98-14C204-BYG'} - self.background = background or [CanData(0x5A, bytes(8), 0), CanData(0x3D6, bytes(8), 2), - CanData(0x186, bytes(8), 2)] + self.background = background or [CanData(0x5A, bytes(8), offset), CanData(0x3D6, bytes(8), offset + 2), + CanData(0x186, bytes(8), offset + 2)] self.queued = [] self.pending = {} self.mux_calls = [] @@ -39,30 +40,29 @@ class FordEcus: self.mux_calls.append(enabled) assert not enabled, 'Ford discovery switched CAN2 onto the OBD network' - def reply(self, address, payload): + def reply(self, address, payload, *, bus=None): + bus = self.offset if bus is None else bus if len(payload) < 8: frames = [bytes([len(payload)]) + payload] else: frames = [bytes([0x10 | (len(payload) >> 8), len(payload) & 0xFF]) + payload[:6]] frames += [bytes([0x20 | (index & 15)]) + payload[offset:offset + 7] for index, offset in enumerate(range(6, len(payload), 7), 1)] - self.queued.append(CanData(address + 8, frames[0].ljust(8, b'\0'), 0)) - self.pending[address] = [CanData(address + 8, data.ljust(8, b'\0'), 0) for data in frames[1:]] + self.queued.append(CanData(address + 8, frames[0].ljust(8, b'\0'), bus)) + self.pending[address, bus] = [CanData(address + 8, data.ljust(8, b'\0'), bus) for data in frames[1:]] def send(self, frames): self.sent.extend(frames) for address, data, bus in frames: - if bus != 0: - continue if data[0] >> 4 == 3: - self.queued.extend(self.pending.pop(address, [])) + self.queued.extend(self.pending.pop((address, bus), [])) elif data[0] >> 4 == 0: request = bytes(data[1:1 + data[0]]) - if address == 0x7DF and request == b'\x22\xf1\x90': - self.reply(0x7E0, b'\x62\xf1\x90' + self.vin.encode()) - elif request == b'\x3e\x00' and any(tx == address for tx, _ in self.versions): + if bus == 0 and address == 0x7DF and request == b'\x22\xf1\x90': + self.reply(0x7E0, b'\x62\xf1\x90' + self.vin.encode(), bus=0) + elif bus == self.offset and request == b'\x3e\x00' and any(tx == address for tx, _ in self.versions): self.reply(address, b'\x7e\x00') - elif (address, request) in self.versions: + elif bus == self.offset and (address, request) in self.versions: self.reply(address, bytes([request[0] + 0x40]) + request[1:] + self.versions[address, request]) def recv(self, wait_for_one=False): @@ -85,8 +85,9 @@ def discover(transport, candidate, *, cached=None): @pytest.mark.parametrize('candidate', CANFD_CARS) -def test_registered_ford_uses_real_normal_bus_vin_fw_can_and_factory(candidate): - transport = FordEcus() +@pytest.mark.parametrize('offset', (0, 4)) +def test_registered_ford_uses_real_normal_bus_vin_fw_can_and_factory(candidate, offset): + transport = FordEcus(offset=offset) ci = discover(transport, candidate) assert transport.mux_calls and set(transport.mux_calls) == {False} assert ci.CP.carFingerprint == candidate and ci.CP.carVin == VIN @@ -95,9 +96,13 @@ def test_registered_ford_uses_real_normal_bus_vin_fw_can_and_factory(candidate): assert ci.CP.safetyConfigs[-1].safetyModel == structs.CarParams.SafetyModel.ford assert len(ci.CP.carFw) == len(transport.versions) for fw in ci.CP.carFw: - assert fw.brand == 'ford' and fw.bus == 0 + assert fw.brand == 'ford' and fw.bus == offset assert fw.fwVersion == transport.versions[fw.address, b'\x22\xf1\x88'] - assert all(frame.src == 0 for frame in transport.sent) + assert all(frame.src in (0, 4) for frame in transport.sent) + assert len(ci.CP.safetyConfigs) == (2 if offset else 1) + if offset: + assert ci.CP.safetyConfigs[0].safetyModel == structs.CarParams.SafetyModel.noOutput + assert ci.CP.safetyConfigs[0].safetyParam == 0 assert any(frame.dat[0] >> 4 == 3 for frame in transport.sent), 'Real ISO-TP flow control was not exercised' @@ -118,7 +123,10 @@ def test_wrong_brand_cache_cannot_replace_live_ford_responses(): assert transport.sent and ci.CP.carFw and all(f.brand == 'ford' for f in ci.CP.carFw) -@pytest.mark.parametrize('drift', ['essential_bus1', 'unique_logging_bus1', 'empty', 'effective_only']) +@pytest.mark.parametrize('drift', [ + 'essential_bus1', 'unique_logging_bus1', 'empty', 'effective_only', 'foreign_auxiliary', + 'unique_auxiliary', 'missing_auxiliary', 'effective_missing_auxiliary', +]) def test_query_topology_drift_refuses_direct_scoped_discovery_before_io(drift): original = fw_versions.FW_QUERY_CONFIGS['ford'] changed = list(original.requests) @@ -128,7 +136,15 @@ def test_query_topology_drift_refuses_direct_scoped_discovery_before_io(drift): changed.append(replace(changed[0], request=[changed[0].request[0], b'\x22\xf1\xff'])) elif drift == 'empty': changed = [] + elif drift == 'missing_auxiliary': + changed = [request for request in changed if request.bus != 4] + elif drift == 'foreign_auxiliary': + changed.append(replace(changed[1], bus=8)) + elif drift == 'unique_auxiliary': + changed.append(replace(changed[1], bus=4, request=[changed[1].request[0], b'\x22\xf1\xff'])) effective = [('ford', original, replace(original.requests[1], bus=1))] if drift == 'effective_only' else fw_versions.REQUESTS + if drift == 'effective_missing_auxiliary': + effective = [(brand, owner, request) for brand, owner, request in effective if brand != 'ford' or request.bus != 4] config = replace(original, requests=changed, extra_ecus=[]) with mock.patch.dict(fw_versions.FW_QUERY_CONFIGS, {'ford': config}), mock.patch.object(fw_versions, 'REQUESTS', effective): assert not normal_bus0_queries() diff --git a/openpilot/selfdrive/pandad/tests/test_aol_protocol.cc b/openpilot/selfdrive/pandad/tests/test_aol_protocol.cc index a344a229de..6b5a0592b3 100644 --- a/openpilot/selfdrive/pandad/tests/test_aol_protocol.cc +++ b/openpilot/selfdrive/pandad/tests/test_aol_protocol.cc @@ -251,7 +251,7 @@ int main() { constexpr uint16_t ford_words[] = {8U, 9U, 10U, 11U, 12U, 13U, 18U, 19U, 32U, 33U, 66U, 67U}; for (uint32_t word = 0U; word <= 65535U; ++word) { bool expected = false; - for (const auto exact : ford_words) expected |= word == exact; + for (const auto exact : ford_words) expected |= (word == exact) || (word == (exact | 256U)); auto ford = status(); ford.safety_mode = 6U; ford.safety_param = static_cast(word); diff --git a/openpilot/selfdrive/pandad/tests/test_panda_owner.cc b/openpilot/selfdrive/pandad/tests/test_panda_owner.cc index 9725e4d808..8248bd03f2 100644 --- a/openpilot/selfdrive/pandad/tests/test_panda_owner.cc +++ b/openpilot/selfdrive/pandad/tests/test_panda_owner.cc @@ -1,4 +1,5 @@ #include +#include "openpilot/starpilot/car/ford/aol_policy.h" #include #include #include @@ -44,7 +45,7 @@ public: OwnerPanda(std::unique_ptr h, uint32_t offset) : Panda(std::move(h), cereal::PandaState::PandaType::RED_PANDA, offset) {} }; -void test_actual_config_slots_and_owner_handle() { +void test_actual_config_slots_and_owner_handle(cereal::CarParams::SafetyModel mode, uint16_t word) { using Mode = cereal::CarParams::SafetyModel; const std::string prefix = "test-panda-owner-" + std::to_string(getpid()); const char *old = getenv("OPENPILOT_PREFIX"); @@ -72,15 +73,15 @@ void test_actual_config_slots_and_owner_handle() { cp.setAlternativeExperience(32); auto slots = cp.initSafetyConfigs(2); slots[0].setSafetyModel(Mode::NO_OUTPUT); slots[0].setSafetyParam(0); - slots[1].setSafetyModel(Mode::HYUNDAI_CANFD); slots[1].setSafetyParam(0x809); + slots[1].setSafetyModel(mode); slots[1].setSafetyParam(word); auto words = capnp::messageToFlatArray(message); auto bytes = words.asBytes(); params.put("CarParams", reinterpret_cast(bytes.begin()), bytes.size()); params.putBool("FirmwareQueryDone", true); params.putBool("ControlsReady", true); safety.configureSafetyMode(true); CHECK(aux->health.safety_mode_pkt == (uint8_t)Mode::NO_OUTPUT); - CHECK(owner->health.safety_mode_pkt == (uint8_t)Mode::HYUNDAI_CANFD); - CHECK(owner->health.safety_param_pkt == 0x809); + CHECK(owner->health.safety_mode_pkt == (uint8_t)mode); + CHECK(owner->health.safety_param_pkt == word); CHECK(extra->health.safety_mode_pkt == (uint8_t)Mode::SILENT); CHECK(aux->health.alternative_experience_pkt == 32 && owner->health.alternative_experience_pkt == 32); CHECK(safety.aolOwner() == nullptr); @@ -96,7 +97,7 @@ void test_actual_config_slots_and_owner_handle() { peer->health.flags_pkt |= HEALTH_FLAG_CONTROLS_ALLOWED; CHECK(owner_safety.aolOwner() == nullptr); peer->health.flags_pkt = 0; - peer->health.safety_param_pkt = 0x809; + peer->health.safety_param_pkt = word; CHECK(owner_safety.aolOwner() == nullptr); peer->health.safety_param_pkt = 0; peer->comms_healthy = false; @@ -106,8 +107,8 @@ void test_actual_config_slots_and_owner_handle() { CHECK(owner_safety.aolOwner() == nullptr); peer->connected = true; } - owner->health.safety_param_pkt = 0x808; CHECK(owner_safety.aolOwner() == nullptr); - owner->health.safety_param_pkt = 0x809; + owner->health.safety_param_pkt = (word ^ 1U); CHECK(owner_safety.aolOwner() == nullptr); + owner->health.safety_param_pkt = word; owner->health.alternative_experience_pkt = 0; CHECK(owner_safety.aolOwner() == nullptr); owner->health.alternative_experience_pkt = 32; slots[0].setSafetyParam(1); @@ -121,8 +122,8 @@ void test_actual_config_slots_and_owner_handle() { params.put("CarParams", reinterpret_cast(data.begin()), data.size()); } owner_safety.configureSafetyMode(false); owner_safety.configureSafetyMode(true); CHECK(owner_safety.aolOwner() == &primary); - const AolSafetyProfile profiles[] = {{(uint8_t)Mode::HYUNDAI_CANFD, [](uint16_t p) { return p == 0x809; }, true}}; - AolProfileRegistry registry{profiles, 1}; AolAxisNegotiator negotiator(registry); + const AolSafetyProfile profiles[] = {{(uint8_t)Mode::HYUNDAI_CANFD, [](uint16_t p) { return p == 0x809; }, true}, FORD_AOL_PROFILE}; + AolProfileRegistry registry{profiles, 2}; AolAxisNegotiator negotiator(registry); owner->aol.capability_flags = 1; aux->aol = owner->aol; aux->aol.permission_mask = 3; aux->aol.request_mask = 3; AolAxisInput axis{true, true, "session", 1, 200000001, 1, true, false}; @@ -142,4 +143,12 @@ void test_actual_config_slots_and_owner_handle() { PandaSafety single({&primary}); CHECK(single.aolOwner() == &primary); } -int main() { return run_native_test(test_actual_config_slots_and_owner_handle); } +void test_registered_owners() { + test_actual_config_slots_and_owner_handle(cereal::CarParams::SafetyModel::HYUNDAI_CANFD, 0x809); + for (uint16_t word : {8U, 9U, 10U, 11U, 12U, 13U, 18U, 19U, 32U, 33U, 66U, 67U}) { + test_actual_config_slots_and_owner_handle(cereal::CarParams::SafetyModel::FORD, word); + test_actual_config_slots_and_owner_handle(cereal::CarParams::SafetyModel::FORD, word | 256U); + } +} + +int main() { return run_native_test(test_registered_owners); } diff --git a/openpilot/selfdrive/ui/layouts/settings/developer.py b/openpilot/selfdrive/ui/layouts/settings/developer.py index cbd0479c38..9b8e8a96c8 100644 --- a/openpilot/selfdrive/ui/layouts/settings/developer.py +++ b/openpilot/selfdrive/ui/layouts/settings/developer.py @@ -2,6 +2,7 @@ from collections.abc import Callable from openpilot.common.params import Params from openpilot.starpilot.car.hyundai.aol import ioniq6_settings_capable +from openpilot.starpilot.car.ford.longitudinal_preferences import release_long_available as ford_release_long_available from opendbc.car.volkswagen.meb_modes import MEB_CARS, settings_capable as meb_settings_capable from openpilot.starpilot.car.volkswagen.preferences import stock_long_requested from openpilot.selfdrive.ui.widgets.ssh_key import ssh_key_item @@ -144,7 +145,7 @@ class DeveloperLayout(Widget): if ui_state.CP is not None: alpha_avail = (meb_settings_capable(ui_state.CP) if ui_state.CP.brand == 'volkswagen' and ui_state.CP.carFingerprint in MEB_CARS else ui_state.CP.alphaLongitudinalAvailable or ioniq6_settings_capable(ui_state.CP)) - if not alpha_avail or (self._is_release and not meb_settings_capable(ui_state.CP)): + if not alpha_avail or (self._is_release and not (meb_settings_capable(ui_state.CP) or ford_release_long_available(ui_state.CP))): self._alpha_long_toggle.set_visible(False) else: self._alpha_long_toggle.set_visible(True) @@ -215,7 +216,8 @@ class DeveloperLayout(Widget): if result == DialogResult.CONFIRM: if (not ui_state.is_offroad() or ui_state.CP is None or not (meb_settings_capable(ui_state.CP) if ui_state.CP.brand == 'volkswagen' and ui_state.CP.carFingerprint in MEB_CARS - else ui_state.CP.alphaLongitudinalAvailable or ioniq6_settings_capable(ui_state.CP))): + else ui_state.CP.alphaLongitudinalAvailable or ioniq6_settings_capable(ui_state.CP)) or + self._is_release and not (meb_settings_capable(ui_state.CP) or ford_release_long_available(ui_state.CP))): self._alpha_long_toggle.action_item.set_state(False) return self._params.put_bool("AlphaLongitudinalEnabled", True, block=True) diff --git a/openpilot/selfdrive/ui/layouts/settings/toggles.py b/openpilot/selfdrive/ui/layouts/settings/toggles.py index 4e54ed892b..8c54cc6f82 100644 --- a/openpilot/selfdrive/ui/layouts/settings/toggles.py +++ b/openpilot/selfdrive/ui/layouts/settings/toggles.py @@ -1,3 +1,4 @@ +from openpilot.starpilot.car.ford.longitudinal_preferences import release_long_available as ford_release_long_available from openpilot.cereal import log from openpilot.common.params import Params, UnknownKeyName from openpilot.system.ui.widgets import Widget @@ -188,7 +189,7 @@ class TogglesLayout(Widget): long_desc = unavailable + " " + tr("openpilot longitudinal control may come in a future update.") if ui_state.CP.alphaLongitudinalAvailable: - if self._is_release: + if self._is_release and not ford_release_long_available(ui_state.CP): long_desc = unavailable + " " + tr("An alpha version of openpilot longitudinal control can be tested, along with " + "Experimental mode, on non-release branches.") else: diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/developer.py b/openpilot/selfdrive/ui/mici/layouts/settings/developer.py index b1e3301f28..2d0e58e56d 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/developer.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/developer.py @@ -8,6 +8,7 @@ from openpilot.system.ui.lib.application import gui_app from openpilot.selfdrive.ui.layouts.settings.common import restart_needed_callback from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.starpilot.car.hyundai.aol import ioniq6_settings_capable +from openpilot.starpilot.car.ford.longitudinal_preferences import release_long_available as ford_release_long_available from opendbc.car.volkswagen.meb_modes import MEB_CARS, settings_capable as meb_settings_capable from openpilot.starpilot.car.volkswagen.preferences import stock_long_requested from openpilot.selfdrive.ui.widgets.ssh_key import SshKeyFetcher @@ -171,7 +172,7 @@ class DeveloperLayoutMici(NavScroller): if ui_state.CP is not None: alpha_avail = (meb_settings_capable(ui_state.CP) if ui_state.CP.brand == 'volkswagen' and ui_state.CP.carFingerprint in MEB_CARS else ui_state.CP.alphaLongitudinalAvailable or ioniq6_settings_capable(ui_state.CP)) - if not alpha_avail or (ui_state.is_release and not meb_settings_capable(ui_state.CP)): + if not alpha_avail or (ui_state.is_release and not (meb_settings_capable(ui_state.CP) or ford_release_long_available(ui_state.CP))): self._alpha_long_toggle.set_visible(False) else: self._alpha_long_toggle.set_visible(True) @@ -194,7 +195,8 @@ class DeveloperLayoutMici(NavScroller): def do_toggle(_state: bool): if (not ui_state.is_offroad() or (_state and (ui_state.CP is None or not (meb_settings_capable(ui_state.CP) if ui_state.CP.brand == 'volkswagen' and ui_state.CP.carFingerprint in MEB_CARS - else ui_state.CP.alphaLongitudinalAvailable or ioniq6_settings_capable(ui_state.CP))))): + else ui_state.CP.alphaLongitudinalAvailable or ioniq6_settings_capable(ui_state.CP)) or + ui_state.is_release and not (meb_settings_capable(ui_state.CP) or ford_release_long_available(ui_state.CP))))): self._update_toggles() return ui_state.params.put_bool("AlphaLongitudinalEnabled", _state, block=True) diff --git a/openpilot/starpilot/aol/vehicle.py b/openpilot/starpilot/aol/vehicle.py index 626868b39a..fa7fd4640f 100644 --- a/openpilot/starpilot/aol/vehicle.py +++ b/openpilot/starpilot/aol/vehicle.py @@ -82,6 +82,10 @@ def native_matches_cp(CP, model: int, param: int) -> bool: 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)) + if CP.brand == 'ford': + config = ford.aol_safety_config(CP) + return bool(config is not None and ford.native_accepts_cp(CP, model, param) and + model == int(config.safetyModel.raw) and param == int(config.safetyParam)) if CP.brand == 'honda' and len(CP.safetyConfigs) == 2: config = honda.aol_safety_config(CP) return bool(config is not None and honda.native_accepts_cp(CP, model, param) and diff --git a/openpilot/starpilot/car/ford/aol.py b/openpilot/starpilot/car/ford/aol.py index c60d17bc6f..006ef1c8ed 100644 --- a/openpilot/starpilot/car/ford/aol.py +++ b/openpilot/starpilot/car/ford/aol.py @@ -1,6 +1,6 @@ from dataclasses import replace from opendbc.car import structs -from opendbc.car.ford.aol import qualified, AOL_WORDS, temporary_restriction +from opendbc.car.ford.aol import qualified, AOL_WORDS, MANUAL, temporary_restriction, aol_safety_config from openpilot.starpilot.aol.intent import AolCardIntent, AOL_TOGGLE from openpilot.starpilot.aol.policy import AolVehiclePolicy @@ -9,7 +9,8 @@ def policy_for(cp): if qualified(cp): return AolVehiclePolicy(intent_supported=True, settings_supported=True, runtime_supported=True, normal_runtime_supported=True, ordinary_axis_ack_required=qualified(cp, marked_only=True), - explicit_latch=True, alternative_experience_addition=32) + explicit_latch=True, alternative_experience_addition=32, + safety_param_addition=MANUAL if cp.transmissionType == structs.CarParams.TransmissionType.manual else 0) return AolVehiclePolicy() @@ -18,7 +19,7 @@ def native_profile_supported(model, param): def native_accepts_cp(cp, model, param): - return qualified(cp, marked_only=True) and native_profile_supported(model, param) and param == cp.safetyConfigs[0].safetyParam + return qualified(cp, marked_only=True) and native_profile_supported(model, param) and param == aol_safety_config(cp).safetyParam def native_latch_rejected(cp, native): @@ -134,9 +135,9 @@ def create_intent(cp, settings): def temporary_eps_restriction(cp, state): - """Exact stock Ford physical EPS pause; intent only, no native permission.""" + """Ford physical EPS pause after LONG is off; intent only, no native permission.""" try: - return bool(qualified(cp, marked_only=True) and not cp.openpilotLongitudinalControl and + return bool(qualified(cp, marked_only=True) and state.steerFaultTemporary and not state.steerFaultPermanent and state.canValid and not state.canTimeout and state.cruiseState.available and not state.cruiseState.enabled and state.gearShifter == structs.CarState.GearShifter.drive and @@ -148,9 +149,9 @@ def temporary_eps_restriction(cp, state): def temporary_eps_brake_recovery(cp, state): - """Stock LAT intent may outlive EPS loss while the brake remains held.""" + """Lateral-only intent may outlive EPS loss while the brake remains held.""" try: - return bool(qualified(cp, marked_only=True) and not cp.openpilotLongitudinalControl and + return bool(qualified(cp, marked_only=True) and state.brakePressed and not state.steerFaultTemporary and not state.steerFaultPermanent and state.canValid and not state.canTimeout and state.cruiseState.available and not state.cruiseState.enabled and state.gearShifter == structs.CarState.GearShifter.drive and not state.regenBraking and not state.gasPressed and diff --git a/openpilot/starpilot/car/ford/aol_policy.h b/openpilot/starpilot/car/ford/aol_policy.h index 1957acdddc..b1b60a5bd4 100644 --- a/openpilot/starpilot/car/ford/aol_policy.h +++ b/openpilot/starpilot/car/ford/aol_policy.h @@ -4,7 +4,7 @@ static inline bool ford_aol_param(uint16_t param) { bool result = false; - switch (param) { + switch (param & ~256U) { case 8U: case 9U: case 10U: case 11U: case 12U: case 13U: case 18U: case 19U: case 32U: case 33U: case 66U: case 67U: result = true; diff --git a/openpilot/starpilot/car/ford/command_pause.py b/openpilot/starpilot/car/ford/command_pause.py index 9b00a26e30..0ff1a684c1 100644 --- a/openpilot/starpilot/car/ford/command_pause.py +++ b/openpilot/starpilot/car/ford/command_pause.py @@ -1,4 +1,4 @@ -"""Keep stock-Ford intent during a short command lease withdrawal; no grant.""" +"""Keep Ford lateral-only intent during a short command lease withdrawal; no grant.""" from opendbc.car import structs from opendbc.car.ford.aol import qualified from openpilot.cereal import log @@ -9,7 +9,7 @@ from openpilot.starpilot.aol.transport_pause import cp_identity def retain_command_pause_intent(feedback, sm, cp, cs, axis, now_ns, *, latched, producer): """Retain only the original fresh LAT-only proof, without renewing any lease.""" try: - if (not qualified(cp, marked_only=True) or cp.openpilotLongitudinalControl or not latched or + if (not qualified(cp, marked_only=True) or not latched or feedback.cp != cp_identity(cp) or feedback.failed or feedback.paused or feedback.pending_event or feedback.awaiting or feedback.retention_started_ns or feedback.retention_pending or feedback.retention_qualified or feedback.source_retention_started_ns or not feedback.lateral_only_baseline or not feedback.lateral_inventory or diff --git a/openpilot/starpilot/car/ford/gas_override.py b/openpilot/starpilot/car/ford/gas_override.py index 80339c16d7..7498a6c74b 100644 --- a/openpilot/starpilot/car/ford/gas_override.py +++ b/openpilot/starpilot/car/ford/gas_override.py @@ -113,8 +113,10 @@ def retained_gas_lateral_native(feedback, sm, cp, cs, axis, now_ns, *, latched, or native.longitudinalAllowed or native.observedMonoTime < feedback.lateral_native_ns or (native.pandaSerial, tuple(p.hardwareSerial for p in native.pandaInventory)) != feedback.lateral_inventory - or not all(p.controlsAllowed for p in native.pandaInventory) - or not all(p.controlsAllowed for p in sm['pandaStates']) + or not all(bool(p.controlsAllowed) == (p.safetyModel == int(structs.CarParams.SafetyModel.ford)) + for p in native.pandaInventory) + or not all(bool(p.controlsAllowed) == (c.safetyModel == structs.CarParams.SafetyModel.ford) + for p, c in zip(sm['pandaStates'], cp.safetyConfigs, strict=True)) ): return None return native diff --git a/openpilot/starpilot/car/ford/longitudinal_preferences.py b/openpilot/starpilot/car/ford/longitudinal_preferences.py new file mode 100644 index 0000000000..d73c8f4b72 --- /dev/null +++ b/openpilot/starpilot/car/ford/longitudinal_preferences.py @@ -0,0 +1,26 @@ +"""Exact Ford release opt-in before parser/controller construction.""" +from opendbc.car.ford.aol import qualified, aol_safety_config +from openpilot.starpilot.saved_source import read_saved + + +def prepare_release_long(cp, params, *, is_release, enabled): + if not is_release or not enabled or cp.brand != 'ford': + return cp + try: + if (cp.alternativeExperience != 0 or not qualified(cp) or not cp.alphaLongitudinalAvailable or + cp.openpilotLongitudinalControl or read_saved(params, 'AlphaLongitudinalEnabled', 8) != (b'1', True) or + read_saved(params, 'SafeMode', 8) not in ((None, True), (b'0', True))): + return cp + candidate = cp.as_reader().as_builder() + aol_safety_config(candidate).safetyParam |= 1 + candidate.openpilotLongitudinalControl = True + return candidate if qualified(candidate) else cp + except (AttributeError, OSError, TypeError, ValueError, OverflowError, IndexError): + return cp + + +def release_long_available(cp): + try: + return bool(cp is not None and cp.brand == 'ford' and cp.alphaLongitudinalAvailable and qualified(cp)) + except (AttributeError, TypeError, ValueError, OverflowError, IndexError): + return False diff --git a/openpilot/starpilot/car/ford/physical_pause.py b/openpilot/starpilot/car/ford/physical_pause.py index 5cdf1fd7cc..9b5c8fa336 100644 --- a/openpilot/starpilot/car/ford/physical_pause.py +++ b/openpilot/starpilot/car/ford/physical_pause.py @@ -113,7 +113,8 @@ def retain_physical_pause_intent(feedback, sm, cp, cs, axis, now_ns, *, latched, or bool(native.longitudinalAllowed) != (eps and long_active) or native.observedMonoTime < feedback.healthy_native_ns or (native.pandaSerial, tuple(p.hardwareSerial for p in native.pandaInventory)) != feedback.healthy_inventory - or any(bool(p.controlsAllowed) != ordinary for p in native.pandaInventory) + or any(bool(p.controlsAllowed) != (ordinary and p.safetyModel == int(structs.CarParams.SafetyModel.ford)) + for p in native.pandaInventory) or not sm.seen['pandaStates'] or not sm.valid['pandaStates'] or not sm.alive['pandaStates'] @@ -125,7 +126,7 @@ def retain_physical_pause_intent(feedback, sm, cp, cs, axis, now_ns, *, latched, int(p.safetyModel.raw) == int(c.safetyModel.raw) and p.safetyParam == c.safetyParam and p.alternativeExperience == cp.alternativeExperience - and bool(p.controlsAllowed) == ordinary + and bool(p.controlsAllowed) == (ordinary and c.safetyModel == structs.CarParams.SafetyModel.ford) and not p.safetyRxChecksInvalid and not p.heartbeatLost and not p.faults diff --git a/openpilot/starpilot/car/ford/reverse_intent.py b/openpilot/starpilot/car/ford/reverse_intent.py index f88958182f..9ac6828d30 100644 --- a/openpilot/starpilot/car/ford/reverse_intent.py +++ b/openpilot/starpilot/car/ford/reverse_intent.py @@ -1,6 +1,7 @@ """Ford qualification for the shared physical Reverse/native join.""" from opendbc.car.ford.aol import qualified +from openpilot.cereal import log from openpilot.starpilot.aol.reverse_intent import retain_reverse_intent as retain_qualified_reverse_intent @@ -10,4 +11,7 @@ def retain_reverse_intent(feedback, sm, cp, cs, axis, now_ns, *, latched, produc except (AttributeError, KeyError, IndexError, TypeError, ValueError, OverflowError): return False return retain_qualified_reverse_intent(feedback, sm, cp, cs, axis, now_ns, latched=latched, producer=producer, - qualified_cp=qualified_cp) + qualified_cp=qualified_cp, + # The preceding brake USER_DISABLE can arrive with the first Reverse frame. + # Retain only the existing fresh LAT-only proof; Reverse still denies axes. + extra_user_events=(log.OnroadEvent.EventName.pedalPressed,)) diff --git a/openpilot/starpilot/galaxy/tests/test_ford_release_alpha.py b/openpilot/starpilot/galaxy/tests/test_ford_release_alpha.py new file mode 100644 index 0000000000..9a24ee5b8b --- /dev/null +++ b/openpilot/starpilot/galaxy/tests/test_ford_release_alpha.py @@ -0,0 +1,92 @@ +"""Ford release opt-in reaches Galaxy's real capability and confirmation owner.""" +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +from opendbc.car import gen_empty_fingerprint +from opendbc.car.car_helpers import interfaces +from openpilot.common.params import Params +from openpilot.common.prefix import OpenpilotPrefix +from openpilot.starpilot.aol.vehicle import policy_for +from openpilot.starpilot.galaxy.settings import AuthorityContext, ContextSource, SettingsChanged, SettingsGateway +from openpilot.starpilot.tests.ford_lifecycle_helpers import FORD_FACTORIES + + +def fixture(identity, *, manual, offset, marked=False): + params = Params() + params.put_bool('IsReleaseBranch', True, block=True) + fp = gen_empty_fingerprint() + fp[offset + 2].update({0x3D6: 8, 0x186: 8}) + if not manual: + fp[offset][0x5A] = 8 + cp = interfaces[identity].get_params(identity, fp, [], False, False, True) + if marked: + policy = policy_for(cp) + cp.alternativeExperience |= policy.alternative_experience_addition + cp.safetyConfigs[-1].safetyParam |= policy.safety_param_addition + state = SimpleNamespace(parked=True, cp=cp, raw=cp.to_bytes()) + gateway = SettingsGateway(params, Mock(spec=ContextSource, sample=lambda: AuthorityContext(state.parked, state.cp, state.raw)), clock=lambda: 10) + return params, state, gateway + + +def row(gateway): + page = gateway.page('vehicle', 'session', b'generation') + index = next(i for i, item in enumerate(page['rows']) if item['label'] == 'Alpha Longitudinal') + return page, index + + +@pytest.mark.parametrize('identity', [identity for identity, _ in FORD_FACTORIES]) +@pytest.mark.parametrize('manual,offset,marked', [(False, 0, False), (True, 0, True), (False, 4, True), (True, 4, False)]) +def test_release_ford_exact_configs_reach_real_galaxy_capability(identity, manual, offset, marked): + with OpenpilotPrefix(): + params, state, gateway = fixture(identity, manual=manual, offset=offset, marked=marked) + try: + original = state.cp.to_dict() + page, index = row(gateway) + assert page['rows'][index]['available'] and not params.get_bool('AlphaLongitudinalEnabled') + assert state.cp.to_dict() == original + finally: + gateway.close() + + +@pytest.mark.parametrize('manual,offset', [(False, 0), (True, 0), (False, 4), (True, 4)]) +def test_release_ford_real_preview_confirm_on_off_and_parked_revocation(manual, offset): + with OpenpilotPrefix(): + params, state, gateway = fixture('FORD_MUSTANG_MACH_E_MK1', manual=manual, offset=offset) + try: + original = state.cp.to_dict() + for choice in ('On', 'Off'): + page, index = row(gateway) + intent = gateway.preview(page['view'], index, 0, 'session', b'generation', value=choice) + assert gateway.confirm(intent['intent'], 'session', b'generation') + assert params.get_bool('AlphaLongitudinalEnabled') == (choice == 'On') + assert state.cp.to_dict() == original + page, index = row(gateway) + intent = gateway.preview(page['view'], index, 0, 'session', b'generation', value='On') + state.parked = False + with pytest.raises(SettingsChanged): + gateway.confirm(intent['intent'], 'session', b'generation') + assert not params.get_bool('AlphaLongitudinalEnabled') + finally: + gateway.close() + + +@pytest.mark.parametrize('failure', ['legacy', 'wrong-owner', 'automatic-manual-marker', 'passive', 'dashcam', 'not-car']) +def test_release_ford_capability_rejects_invalid_native_owner_and_passive_config(failure): + with OpenpilotPrefix(): + params, state, gateway = fixture('FORD_MUSTANG_MACH_E_MK1', manual=False, offset=0) + try: + if failure == 'legacy': + state.cp.safetyConfigs[-1].safetyParam = 3 + elif failure == 'wrong-owner': + state.cp.safetyConfigs[-1].safetyParam = 66 + elif failure == 'automatic-manual-marker': + state.cp.safetyConfigs[-1].safetyParam |= 256 + else: + setattr(state.cp, {'not-car': 'notCar', 'dashcam': 'dashcamOnly'}.get(failure, failure), True) + state.raw = state.cp.to_bytes() + page = gateway.page('vehicle', 'session', b'generation') + assert not any(item['label'] == 'Alpha Longitudinal' for item in page['rows']) + assert not params.get_bool('AlphaLongitudinalEnabled') + finally: + gateway.close() diff --git a/openpilot/starpilot/tests/ford_lifecycle_helpers.py b/openpilot/starpilot/tests/ford_lifecycle_helpers.py index 0451059144..bd4711d966 100644 --- a/openpilot/starpilot/tests/ford_lifecycle_helpers.py +++ b/openpilot/starpilot/tests/ford_lifecycle_helpers.py @@ -95,13 +95,17 @@ def ford_joined(identity, alpha, hands_free, monkeypatch, record_property): for key, value in (('FordLateralMode', b'1'), ('PauseAOLOnBrake', b'0.0'), ('CancelButtonControl', b'0'), ('MainCruiseButtonControl', b'0')): Path(saved.get_param_path(key)).write_bytes(value) - from pathlib import Path - Path(saved.get_param_path("FordHandsFreeCluster")).write_bytes(b"1" if hands_free else b"0") fp = gen_empty_fingerprint() - fp[0][0x5A] = 8 - fp[0].update({0x3A6: 8, 0x3A7: 8}) - fp[2].update({0x3D6: 8, 0x186: 8}) + topology = getattr(monkeypatch, 'ford_topology', 'automatic') + offset = 4 if topology == 'multi-panda' else 0 + manual = topology == 'manual' or topology == 'manual-multi' + if topology == 'manual-multi': + offset = 4 + if not manual: + fp[offset][0x5A] = 8 + fp[offset].update({0x3A6: 8, 0x3A7: 8}) + fp[offset + 2].update({0x3D6: 8, 0x186: 8}) name, base = identity observed = (name, fp, '0' * 17, [], structs.CarParams.FingerprintSource.can, True) try: @@ -112,15 +116,15 @@ def ford_joined(identity, alpha, hands_free, monkeypatch, record_property): host = card_module.Car() assert host.CP.alternativeExperience == 32 and host.aol_card_intent is not None assert str(host.CP.carFingerprint) == str(name) - expected_long = bool(alpha and not release) + expected_long = bool(alpha) assert host.CP.openpilotLongitudinalControl == expected_long - assert host.CP.safetyConfigs[0].safetyParam == base | int(expected_long) + assert host.CP.safetyConfigs[-1].safetyParam == base | int(expected_long) | (256 if manual else 0) record_property('ford_factory_identity', str(name)) record_property('ford_saved_alpha', alpha) record_property('ford_release', release) record_property('ford_longitudinal', expected_long) - record_property('ford_safety_word', int(host.CP.safetyConfigs[0].safetyParam)) - native.reset(host.CP.safetyConfigs[0].safetyParam) + record_property('ford_safety_word', int(host.CP.safetyConfigs[-1].safetyParam)) + native.reset(host.CP.safetyConfigs[-1].safetyParam) native.word &= ~1 # Real empty update registers lazily accessed parser messages before RX. # Card.controls_update owns CI initialization; no flag is assigned here. @@ -152,12 +156,14 @@ def ford_joined(identity, alpha, hands_free, monkeypatch, record_property): extras += [('SteeringPinion_Data_Alt', 0, {'StePinRelInit_An_Sns': 0}), ('ParkAid_Data', 0, {'ExtSteeringAngleReq2': 0})] if host.CP.flags & FordFlags.LKA_STEERING: extras += [('LateralMotionControl', 2, {})] - extras += [('Side_Detect_L_Stat', 2, {}), ('Side_Detect_R_Stat', 2, {})] + extras += [('Side_Detect_L_Stat', 2 if host.CP.flags & FordFlags.CANFD else 0, {}), + ('Side_Detect_R_Stat', 2 if host.CP.flags & FordFlags.CANFD else 0, {})] preframes += [native.packer.make_can_msg(message, bus, fields) for message, bus, fields in extras] - state = host.CI.update([(10_000_000_000 + native.tick * 10_000_000, preframes)]) + state = host.CI.update([(10_000_000_000 + native.tick * 10_000_000, [(addr, data, bus + offset) for addr, data, bus in preframes])]) assert not native.safety.get_controls_allowed() and native.safety.aol_get_permission_mask() == 0 assert state.canValid and not state.canTimeout - yield SimpleNamespace(host=host, native=native, safety=native.safety, initial_cp=host.CP.to_dict(), expected_long=expected_long, hands_free=hands_free) + yield SimpleNamespace(host=host, native=native, safety=native.safety, initial_cp=host.CP.to_dict(), + expected_long=expected_long, hands_free=hands_free, offset=offset) finally: native.tearDown() if host is not None: @@ -176,6 +182,7 @@ def run_actual_producer(ford_joined, monkeypatch, *, scenario='reverse', reengag steps = start_tick = native.tick warmup_waits = qualified_samples = 0 active_lat_frames = active_long_frames = 0 + transition_trace = [] long_parser = CANParser(DBC[host.CP.carFingerprint][Bus.pt], [('ACCDATA', 50)], 0) class ClockMappedSafety: @@ -292,14 +299,17 @@ def run_actual_producer(ford_joined, monkeypatch, *, scenario='reverse', reengag extras.append(('ParkAid_Data', 0, {'ExtSteeringAngleReq2': 0})) if host.CP.flags & FordFlags.LKA_STEERING and steps % 5 == 0: extras.append(('LateralMotionControl', 2, {})) - extras += [('Side_Detect_L_Stat', 2, {}), ('Side_Detect_R_Stat', 2, {})] + extras += [('Side_Detect_L_Stat', 2 if host.CP.flags & FordFlags.CANFD else 0, {}), + ('Side_Detect_R_Stat', 2 if host.CP.flags & FordFlags.CANFD else 0, {})] frames += [native.packer.make_can_msg(name, bus, fields) for name, bus, fields in extras] + frames = [(addr, data, bus + joined.offset) for addr, data, bus in frames] batch = messaging.log_from_bytes(card_module.can_list_to_can_capnp(frames)).as_builder() batch.logMonoTime = now[0] queued[:] = [batch.to_bytes()] - config = host.CP.safetyConfigs[0] + config = host.CP.safetyConfigs[-1] slot = PandaSlot( - 0, 'authority-panda', config.safetyModel.raw, config.safetyParam, 32, bool(safety.get_controls_allowed()), not bool(safety.safety_config_valid()) + len(host.CP.safetyConfigs) - 1, 'authority-panda', config.safetyModel.raw, config.safetyParam, 32, + bool(safety.get_controls_allowed()), not bool(safety.safety_config_valid()) ) native_state = SafetyState( 1, @@ -315,16 +325,20 @@ def run_actual_producer(ford_joined, monkeypatch, *, scenario='reverse', reengag 'authority-panda', str(prior_axis.aolAxisState.sessionId) if prior_axis is not None else host.aol_axis_session(), sourcePandaStatesMonoTime=now[0], - pandaInventory=(slot,), + pandaInventory=(slot,) if len(host.CP.safetyConfigs) == 1 else ( + PandaSlot(0, 'silent-panda-0', host.CP.safetyConfigs[0].safetyModel.raw, 0, 32, False, False), slot), ) wire = messaging.new_message('aolSafetyWire', 0, valid=True, logMonoTime=now[0]) wire.aolSafetyWire = encode_safety(native_state) - panda = messaging.new_message('pandaStates', 1, valid=True, logMonoTime=now[0]) - panda.pandaStates[0].safetyModel = config.safetyModel - panda.pandaStates[0].safetyParam = config.safetyParam - panda.pandaStates[0].alternativeExperience = 32 - panda.pandaStates[0].controlsAllowed = slot.controlsAllowed - panda.pandaStates[0].safetyRxChecksInvalid = slot.safetyRxChecksInvalid + panda = messaging.new_message('pandaStates', len(host.CP.safetyConfigs), valid=True, logMonoTime=now[0]) + panda.pandaStates[-1].safetyModel = config.safetyModel + panda.pandaStates[-1].safetyParam = config.safetyParam + panda.pandaStates[-1].alternativeExperience = 32 + panda.pandaStates[-1].controlsAllowed = slot.controlsAllowed + panda.pandaStates[-1].safetyRxChecksInvalid = slot.safetyRxChecksInvalid + if len(host.CP.safetyConfigs) == 2: + panda.pandaStates[0].safetyModel = host.CP.safetyConfigs[0].safetyModel + panda.pandaStates[0].alternativeExperience = 32 device = messaging.new_message('deviceState', valid=True, logMonoTime=now[0]) device.deviceState.started, device.deviceState.startedMonoTime = True, 9_000_000_000 events = [wire.as_reader(), panda.as_reader(), device.as_reader()] @@ -364,6 +378,8 @@ def run_actual_producer(ford_joined, monkeypatch, *, scenario='reverse', reengag native_request_long=bool(native_request & 2), panda_controls_allowed=bool(safety.get_controls_allowed()), panda_fault=slot.safetyRxChecksInvalid, + panda_state_message=panda, + safety_message=wire, ) assert state.to_dict() == parsed_before_producer, 'Authority harness changed actual parsed CarState' previous_sd.update(sd.pm.messages) @@ -380,7 +396,7 @@ def run_actual_producer(ford_joined, monkeypatch, *, scenario='reverse', reengag for frame in published['sendcan'].sendcan: from opendbc.safety.tests.libsafety import libsafety_py - assert safety.safety_tx_hook(libsafety_py.make_CANPacket(frame.address, frame.src, frame.dat)) + assert safety.safety_tx_hook(libsafety_py.make_CANPacket(frame.address, frame.src - joined.offset, frame.dat)) data, address = frame.dat, frame.address if address in (0x3CA, 0x3D3, 0x3D6): active = ((data[4] >> 2) & 7) != 0 if address == 0x3D3 else ((data[0] >> 4) & 7) != 0 if address == 0x3D6 else (data[0] >> 5) in (2, 4) @@ -388,7 +404,7 @@ def run_actual_producer(ford_joined, monkeypatch, *, scenario='reverse', reengag assert command.latActive and permission & 1 active_lat_frames += 1 elif address == 0x186: - long_parser.update([(source_ns, [(address, bytes(data), frame.src)])]) + long_parser.update([(source_ns, [(address, bytes(data), frame.src - joined.offset)])]) active = bool(long_parser.vl['ACCDATA']['Cmbb_B_Enbl']) assert active == bool(command.longActive) active_long_frames += int(active) @@ -417,6 +433,11 @@ def run_actual_producer(ford_joined, monkeypatch, *, scenario='reverse', reengag assert not axis.longitudinalActive and not command.longActive elif command.longActive: assert permission & 2 and safety.get_controls_allowed() + transition_trace.append((steps, str(state.gearShifter), bool(state.brakePressed), bool(host.aol_card_intent.allowed_latch), + bool(command.latActive), bool(command.longActive), permission, native_request, + str(axis.faultReason), bool(host.aol_transport_feedback.failed), + str(host.aol_transport_feedback.pending_event), + tuple(str(event.name) for event in host.sm['onroadEvents']))) return state, axis, sd_state, command def joined_packet(frame): @@ -429,6 +450,38 @@ def run_actual_producer(ford_joined, monkeypatch, *, scenario='reverse', reengag assert 0 <= warmup_waits < 120 and qualified_samples >= 100 assert host.ci_initialized # Read-only witness: real controls_update completed init. assert not host.aol_card_intent.allowed_latch + if scenario == 'configuration-parity': + for _ in range(40): + state, axis, sd_state, command = step(engaged=True) + assert command.latActive and host.aol_card_intent.allowed_latch + assert bool(command.longActive) == joined.expected_long + for _ in range(30): + state, axis, sd_state, command = step(engaged=True, fault='gas') + assert command.latActive and not command.longActive and host.aol_card_intent.allowed_latch + for _ in range(30): + state, axis, sd_state, command = step(engaged=True) + assert command.latActive and bool(command.longActive) == joined.expected_long + for _ in range(30): + state, axis, sd_state, command = step(brake=True) + assert host.aol_card_intent.allowed_latch and command.latActive and not command.longActive + for _ in range(30): + state, axis, sd_state, command = step(reverse=True) + assert host.aol_card_intent.allowed_latch and not command.latActive and not command.longActive, transition_trace[-40:] + for _ in range(40): + state, axis, sd_state, command = step() + assert command.latActive and not command.longActive + for _ in range(40): + state, axis, sd_state, command = step(fault='temporary-eps') + assert host.aol_card_intent.allowed_latch and not command.latActive, transition_trace[-40:] + for _ in range(40): + state, axis, sd_state, command = step() + assert command.latActive and not command.longActive + for _ in range(30): + state, axis, sd_state, command = step(main_off=True) + assert not host.aol_card_intent.allowed_latch and not command.latActive and not command.longActive + assert active_lat_frames > 0 + assert bool(active_long_frames) == joined.expected_long + return assert scenario in ('route24', 'route24-mixed-eps', 'route2a', 'route2a-main', 'route2a-main-fault') if scenario in ('route24', 'route24-mixed-eps', 'route2a', 'route2a-main', 'route2a-main-fault'): mixed_eps = scenario in ('route24-mixed-eps', 'route2a', 'route2a-main', 'route2a-main-fault') diff --git a/openpilot/starpilot/tests/test_ford_angle.py b/openpilot/starpilot/tests/test_ford_angle.py index 29ab86f345..b51dd01187 100644 --- a/openpilot/starpilot/tests/test_ford_angle.py +++ b/openpilot/starpilot/tests/test_ford_angle.py @@ -117,7 +117,7 @@ class TestFordAngleStartup(unittest.TestCase): observed[0][0x5A] = 8 observed[2].update({0x3D6: 8, 0x186: 8}) cp = CarInterface.get_params(identity, observed, [], alpha, release, False) - long = alpha and (identity not in CANFD or not release) + long = alpha self.assertEqual(cp.openpilotLongitudinalControl, long) self.assertTrue(cp.pcmCruise) self.assertEqual(cp.alternativeExperience, 0) diff --git a/openpilot/starpilot/tests/test_ford_ci_registration.py b/openpilot/starpilot/tests/test_ford_ci_registration.py new file mode 100644 index 0000000000..8065ac29f0 --- /dev/null +++ b/openpilot/starpilot/tests/test_ford_ci_registration.py @@ -0,0 +1,37 @@ +"""Qualified Ford configurations remain collected by both CI lanes.""" +import ast +from tools.ci import run_vehicle_tests as runner + + +FAMILY_TARGETS = ( + 'openpilot/starpilot/tests/test_ford_configuration_parity.py', + 'openpilot/starpilot/tests/test_ford_configuration_admission.py', + 'openpilot/starpilot/tests/test_ford_release_long_preferences.py', + 'openpilot/selfdrive/car/tests/test_registered_ford_discovery.py', + 'opendbc_repo/opendbc/safety/tests/test_ford_configuration_parity.py', + 'opendbc_repo/opendbc/safety/tests/test_ford_alpha_aol.py', + 'opendbc_repo/opendbc/safety/tests/test_ford_full_engagement.py', +) + + +def test_both_actual_ci_selectors_collect_all_qualified_ford_configuration_contracts(): + for suite in ('aol-lifecycle', 'safety-release'): + selected = runner.targets_for(suite) + for path in FAMILY_TARGETS: + assert path in selected + assert (runner.ROOT / path).is_file() + release = runner.targets_for('safety-release') + prefix = 'opendbc_repo/opendbc/safety/tests/test_ford_mach_e_extended.py::' + assert prefix + 'TestFordMachELongReleaseExtended' in release + assert prefix + 'TestFordMachELongReleaseDenied' not in release + source = ast.parse((runner.ROOT / prefix.split('::', 1)[0]).read_text()) + assert any(isinstance(node, ast.ClassDef) and node.name == 'TestFordMachELongReleaseExtended' for node in source.body) + + +def test_actual_host_collector_discovers_release_ui_guards(tmp_path): + from tools.ci.run_host_tests import pytest_files + name = 'openpilot/starpilot/ui/tests/test_ford_release_alpha.py' + target = tmp_path / name + target.parent.mkdir(parents=True) + target.write_bytes((runner.ROOT / name).read_bytes()) + assert name in pytest_files(tmp_path) diff --git a/openpilot/starpilot/tests/test_ford_configuration_admission.py b/openpilot/starpilot/tests/test_ford_configuration_admission.py new file mode 100644 index 0000000000..6cb2072d0c --- /dev/null +++ b/openpilot/starpilot/tests/test_ford_configuration_admission.py @@ -0,0 +1,65 @@ +"""Ford dual-Panda and transmission admission remains exact and fail-closed.""" +from dataclasses import replace +import pytest +from opendbc.car import gen_empty_fingerprint, structs +from opendbc.car.ford.interface import CarInterface +from opendbc.car.ford.aol import qualified +from openpilot.starpilot.aol.vehicle import native_matches_cp, policy_for +from openpilot.starpilot.aol.runtime import native_inventory_matches_cp +from openpilot.starpilot.aol.wire import PandaSlot, SafetyState +from openpilot.starpilot.tests.ford_lifecycle_helpers import FORD_FACTORIES + + +@pytest.mark.parametrize('identity,base', FORD_FACTORIES) +@pytest.mark.parametrize('alpha', [False, True]) +@pytest.mark.parametrize('manual', [False, True]) +def test_exact_dual_owner_and_manual_identity(identity, base, alpha, manual): + fp = gen_empty_fingerprint() + if not manual: + fp[4][0x5A] = 8 + fp[6].update({0x3D6: 8, 0x186: 8}) + cp = CarInterface.get_params(identity, fp, [], alpha, True, False) + policy = policy_for(cp) + cp.safetyConfigs[-1].safetyParam |= policy.safety_param_addition + cp.alternativeExperience |= policy.alternative_experience_addition + word = base | int(alpha) | (256 if manual else 0) + assert cp.safetyConfigs[-1].safetyParam == word + assert qualified(cp, marked_only=True) and native_matches_cp(cp, 6, word) + for field, value in [('safetyParam', word ^ 256), ('safetyParam', word | 512), ('safetyModel', structs.CarParams.SafetyModel.noOutput)]: + bad = cp.as_reader().as_builder() + setattr(bad.safetyConfigs[-1], field, value) + assert not qualified(bad) and not native_matches_cp(bad, 6, word) + for field, value in [('safetyParam', 1), ('safetyModel', structs.CarParams.SafetyModel.ford)]: + bad = cp.as_reader().as_builder() + setattr(bad.safetyConfigs[0], field, value) + assert not qualified(bad) and not native_matches_cp(bad, 6, word) + bad = cp.as_reader().as_builder() + bad.safetyConfigs = [cp.safetyConfigs[0], cp.safetyConfigs[0], cp.safetyConfigs[-1]] + assert not qualified(bad) + slots = (PandaSlot(0, 'passive', int(cp.safetyConfigs[0].safetyModel.raw), 0, 32, False, False), PandaSlot(1, 'ford', 6, word, 32, True, False)) + state = SafetyState(1, True, 100, 200, 6, word, True, bool(alpha), True, bool(alpha), 'ford', 'session', + sourcePandaStatesMonoTime=100, pandaInventory=slots) + assert native_inventory_matches_cp(state, cp) + assert not native_inventory_matches_cp(replace(state, sourcePandaStatesMonoTime=0, pandaInventory=()), cp) + for index, changes in [(0, {'controlsAllowed': True}), (0, {'safetyParam': 1}), (0, {'hardwareSerial': 'ford'}), + (0, {'heartbeatLost': True}), (1, {'safetyRxChecksInvalid': True}), + (1, {'slotIndex': 0}), (1, {'safetyParam': word ^ 1}), (1, {'alternativeExperience': 0})]: + invalid = list(slots) + invalid[index] = replace(invalid[index], **changes) + assert not native_inventory_matches_cp(replace(state, pandaInventory=tuple(invalid)), cp) + + +def test_marked_manual_policy_survives_cards_alternative_experience_reset(): + fp = gen_empty_fingerprint() + cp = CarInterface.get_params(FORD_FACTORIES[0][0], fp, [], False, False, False) + initial = policy_for(cp) + assert cp.safetyConfigs[-1].safetyParam == 32 and initial.safety_param_addition == 256 + cp.safetyConfigs[-1].safetyParam |= initial.safety_param_addition + cp.alternativeExperience |= initial.alternative_experience_addition + assert native_matches_cp(cp, 6, 288) + cp.alternativeExperience = 0 # Car resets this before reapplying saved settings. + recovered = policy_for(cp) + assert recovered.intent_supported and recovered.safety_param_addition == 256 + cp.safetyConfigs[-1].safetyParam |= recovered.safety_param_addition + cp.alternativeExperience |= recovered.alternative_experience_addition + assert native_matches_cp(cp, 6, 288) diff --git a/openpilot/starpilot/tests/test_ford_configuration_parity.py b/openpilot/starpilot/tests/test_ford_configuration_parity.py new file mode 100644 index 0000000000..9d0a84a291 --- /dev/null +++ b/openpilot/starpilot/tests/test_ford_configuration_parity.py @@ -0,0 +1,19 @@ +"""Actual Ford factory, parser, Card, SelfdriveD, Controls and native matrix. + +CAN, perception, USB and clocks are synthetic; no physical acceptance claim. +""" +import pytest +from openpilot.starpilot.tests.ford_lifecycle_helpers import FORD_FACTORIES, ford_joined as ford_joined, run_actual_producer + + +@pytest.fixture(autouse=True) +def configuration_topology(monkeypatch, topology): + monkeypatch.ford_topology = topology + + +@pytest.mark.parametrize('identity', FORD_FACTORIES) +@pytest.mark.parametrize('alpha', [False, True]) +@pytest.mark.parametrize('hands_free', [False]) +@pytest.mark.parametrize('topology', ['automatic', 'manual', 'multi-panda', 'manual-multi']) +def test_actual_configuration_lifecycle(ford_joined, monkeypatch): + run_actual_producer(ford_joined, monkeypatch, scenario='configuration-parity') diff --git a/openpilot/starpilot/tests/test_ford_manual_ordinary.py b/openpilot/starpilot/tests/test_ford_manual_ordinary.py new file mode 100644 index 0000000000..b518159631 --- /dev/null +++ b/openpilot/starpilot/tests/test_ford_manual_ordinary.py @@ -0,0 +1,46 @@ +"""Manual AOL marking cannot replace existing ordinary/angle configuration words.""" +from pathlib import Path +from unittest.mock import patch +import pytest +from opendbc.car import car_helpers, gen_empty_fingerprint, structs +from opendbc.car.ford.values import CAR +from openpilot.cereal import messaging +from openpilot.common.params import Params +from openpilot.common.prefix import OpenpilotPrefix +from openpilot.selfdrive.car import card as card_module + + +@pytest.mark.parametrize('identity,fd', [(CAR.FORD_ESCAPE_MK4, False), (CAR.FORD_MUSTANG_MACH_E_MK1, True)]) +@pytest.mark.parametrize('alpha', [False, True]) +@pytest.mark.parametrize('release', [False, True]) +@pytest.mark.parametrize('offset', [0, 4]) +def test_actual_manual_angle_startup_preserves_original_unmarked_owner(identity, fd, alpha, release, offset, monkeypatch): + monkeypatch.setenv('SIMULATION', '1') + monkeypatch.setenv('REPLAY', '1') + monkeypatch.setenv('AOL_REPLAY_RUNTIME', '0') + fp = gen_empty_fingerprint() + fp[offset + 2].update({0x3D6: 8, 0x186: 8}) + observed = (identity, fp, '0' * 17, [], structs.CarParams.FingerprintSource.can, True) + with OpenpilotPrefix(): + saved = Params() + for key, value in [('OpenpilotEnabledToggle', True), ('AlwaysOnLateral', False), ('SafeMode', False), + ('AlphaLongitudinalEnabled', alpha), ('IsReleaseBranch', release), ('FordHumanTurnDetection', False)]: + saved.put_bool(key, value, block=True) + Path(saved.get_param_path('FordLateralMode')).write_bytes(b'1') + with patch.object(car_helpers, 'fingerprint', return_value=observed), \ + patch.object(card_module, 'can_comm_callbacks', return_value=(lambda *args: [], lambda frames: None)), \ + patch.object(card_module.messaging, 'recv_one_retry', return_value=messaging.new_message('can', 1)): + host = card_module.Car() + try: + assert host.CP.transmissionType == structs.CarParams.TransmissionType.manual + assert host.CP.alternativeExperience == 0 and host.aol_card_intent is None + assert host.CP.minEnableSpeed == pytest.approx(20 * .44704) + assert host.CP.safetyConfigs[-1].safetyParam == 128 + (2 if fd else 0) + int(alpha and not release) + assert host.CP.openpilotLongitudinalControl == bool(alpha and not release) + assert host.CI.CC.bp_lat is not None + if offset: + assert host.CP.safetyConfigs[0].safetyModel == structs.CarParams.SafetyModel.noOutput + assert host.CP.safetyConfigs[0].safetyParam == 0 + assert saved.get_bool('AlphaLongitudinalEnabled') == alpha + finally: + host.vehicle_startup.close() diff --git a/openpilot/starpilot/tests/test_ford_release_long_preferences.py b/openpilot/starpilot/tests/test_ford_release_long_preferences.py new file mode 100644 index 0000000000..e11ac0afda --- /dev/null +++ b/openpilot/starpilot/tests/test_ford_release_long_preferences.py @@ -0,0 +1,62 @@ +"""Saved Ford release intent is consumed before construction, with exact admission.""" +from types import SimpleNamespace +import pytest +from opendbc.car import gen_empty_fingerprint +from opendbc.car.ford.interface import CarInterface +from opendbc.car.ford.values import CAR +from openpilot.starpilot.car.ford.longitudinal_preferences import prepare_release_long +from openpilot.starpilot.tests.ford_lifecycle_helpers import FORD_FACTORIES + + +def saved(tmp_path, alpha=b'1', safe=b'0'): + for key, value in [('AlphaLongitudinalEnabled', alpha), ('SafeMode', safe)]: + if value is not None: + (tmp_path / key).write_bytes(value) + return SimpleNamespace(get_param_path=lambda key: str(tmp_path / key)) + + +@pytest.mark.parametrize('identity,base', FORD_FACTORIES) +@pytest.mark.parametrize('manual', [False, True]) +@pytest.mark.parametrize('offset', [0, 4]) +def test_release_long_changes_only_selected_owner_and_long_selection(identity, base, manual, offset, tmp_path): + fp = gen_empty_fingerprint() + if not manual: + fp[offset][0x5A] = 8 + fp[offset + 2].update({0x3D6: 8, 0x186: 8}) + stock = CarInterface.get_params(identity, fp, [], False, True, False) + stock.carVin = '0' * 17 + stock.fuzzyFingerprint = True + before = stock.to_dict() + selected = prepare_release_long(stock, saved(tmp_path), is_release=True, enabled=True) + expected = stock.as_reader().as_builder() + expected.openpilotLongitudinalControl = True + expected.safetyConfigs[-1].safetyParam = base | 1 + assert stock.to_dict() == before + assert selected.to_dict() == expected.to_dict() + + +@pytest.mark.parametrize('alpha,safe,release,enabled', [ + (None, b'0', True, True), (b'0', b'0', True, True), (b'1junk', b'0', True, True), + (b'1', b'1', True, True), (b'1', b'bad', True, True), (b'1', b'0', False, True), + (b'1', b'0', True, False), +]) +def test_release_opt_in_off_invalid_safe_mode_debug_and_disabled_stay_unchanged(alpha, safe, release, enabled, tmp_path): + fp = gen_empty_fingerprint() + fp[0][0x5A] = 8 + cp = CarInterface.get_params(CAR.FORD_MUSTANG_MACH_E_MK1, fp, [], False, True, False) + assert prepare_release_long(cp, saved(tmp_path, alpha, safe), is_release=release, enabled=enabled) is cp + + +@pytest.mark.parametrize('restriction', ['secoc', 'extra-panda', 'angle', 'foreign-brand']) +def test_release_never_promotes_unowned_configuration(restriction, tmp_path): + fp = gen_empty_fingerprint() + fp[0][0x5A] = 8 + fp[2].update({0x3D6: 16 if restriction == 'secoc' else 8, 0x186: 16 if restriction == 'secoc' else 8}) + cp = CarInterface.get_params(CAR.FORD_MUSTANG_MACH_E_MK1, fp, [], False, True, False) + if restriction == 'extra-panda': + cp.safetyConfigs = [cp.safetyConfigs[0], cp.safetyConfigs[0]] + elif restriction == 'angle': + cp.safetyConfigs[-1].safetyParam = 130 + elif restriction == 'foreign-brand': + cp.brand = 'hyundai' + assert prepare_release_long(cp, saved(tmp_path), is_release=True, enabled=True) is cp diff --git a/openpilot/starpilot/tests/test_ford_reverse_intent.py b/openpilot/starpilot/tests/test_ford_reverse_intent.py index 5a1477705a..b7f4334b76 100644 --- a/openpilot/starpilot/tests/test_ford_reverse_intent.py +++ b/openpilot/starpilot/tests/test_ford_reverse_intent.py @@ -340,3 +340,24 @@ def test_newer_active_proof_cannot_extend_original_healthy_source_lease(): def test_reverse_qualification_keeps_malformed_configuration_fail_closed(): assert not retain_reverse_intent(None, None, None, None, None, NOW, latched=True, producer=PRODUCER) + + +@pytest.mark.parametrize('event,immediate,soft,expected', [ + ('pedalPressed', False, False, True), ('pedalPressed', True, False, False), + ('pedalPressed', False, True, False), ('controlsMismatch', True, False, False), + ('steerUnavailable', True, False, False), +]) +def test_brake_user_disable_at_reverse_join_retains_only_existing_lateral_intent(event, immediate, soft, expected): + cp, cs, feedback = baseline() + cs.gearShifter = structs.CarState.GearShifter.reverse + sm, native = sample(cp, NOW + 10_000_000, NOW + 20_000_000, 2, allowed=False) + change(sm, 'onroadEvents', lambda events: setattr(events[0], 'name', event)) + change(sm, 'onroadEvents', lambda events: setattr(events[0], 'immediateDisable', immediate)) + change(sm, 'onroadEvents', lambda events: setattr(events[0], 'softDisable', soft)) + before = (feedback.healthy_feedback_ns, feedback.healthy_native_ns, feedback.lateral_baseline_ns, + feedback.lateral_source_ns, feedback.lateral_native_ns) + assert retain_reverse_intent(feedback, sm, cp, cs, sm['aolAxisState'], NOW + 20_000_000, + latched=True, producer=PRODUCER) == expected + assert before == (feedback.healthy_feedback_ns, feedback.healthy_native_ns, feedback.lateral_baseline_ns, + feedback.lateral_source_ns, feedback.lateral_native_ns) + assert not native.lateralAllowed and not native.longitudinalAllowed diff --git a/openpilot/starpilot/tests/test_ford_shared_stop_isolation.py b/openpilot/starpilot/tests/test_ford_shared_stop_isolation.py new file mode 100644 index 0000000000..a5c3af46b0 --- /dev/null +++ b/openpilot/starpilot/tests/test_ford_shared_stop_isolation.py @@ -0,0 +1,51 @@ +"""The HKG healthy-stop exception cannot widen a Ford native denial.""" +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +from opendbc.car import gen_empty_fingerprint +from opendbc.car.ford.interface import CarInterface +from openpilot.starpilot.aol.vehicle import policy_for +from openpilot.starpilot.car.hyundai.aol import expected_stopped_lateral_denial +from openpilot.starpilot.tests.ford_lifecycle_helpers import FORD_FACTORIES + + +@pytest.mark.parametrize('identity,base', FORD_FACTORIES) +@pytest.mark.parametrize('alpha', [False, True]) +@pytest.mark.parametrize('manual,offset', [(False, 0), (True, 0), (False, 4), (True, 4)]) +def test_hkg_stop_exception_never_qualifies_ford(identity, base, alpha, manual, offset, monkeypatch): + fp = gen_empty_fingerprint() + if not manual: + fp[offset][0x5A] = 8 + fp[offset + 2].update({0x3D6: 8, 0x186: 8}) + cp = CarInterface.get_params(identity, fp, [], alpha, True, False) + policy = policy_for(cp) + cp.safetyConfigs[-1].safetyParam |= policy.safety_param_addition + cp.alternativeExperience |= policy.alternative_experience_addition + assert cp.safetyConfigs[-1].safetyParam == (base | int(alpha) | (256 if manual else 0)) + transport = Mock(side_effect=AssertionError('Ford must reject the HKG-only exception before native transport')) + monkeypatch.setattr('openpilot.starpilot.aol.transport_pause._configured_transport_native', transport) + assert not expected_stopped_lateral_denial(cp, SimpleNamespace(), SimpleNamespace(), sm=None, axis_session_id='ford', now_ns=100) + transport.assert_not_called() + from openpilot.starpilot.car.hyundai.aol import source_deadline_supported + from opendbc.car.hyundai.canfd_stock_aol import qualified as hkg_stock_qualified + assert not source_deadline_supported(cp) + assert not hkg_stock_qualified(cp, marked_only=True) + from openpilot.starpilot.car.volkswagen.aol import qualified as vw_qualified, temporary_eps_restriction + assert not vw_qualified(cp, marked_only=True) + assert not temporary_eps_restriction(cp, SimpleNamespace()) + + from openpilot.starpilot.car.subaru.smoothing import prepare, install, read_frozen, control_maximum, seconds, filter_action + before = cp.to_dict() + params = Mock() + assert prepare(cp, object()).to_dict() == before + assert install(cp, b'ford', object(), params) is None + assert read_frozen(cp, params) == 0. and control_maximum(SimpleNamespace(CP=cp)) == 0. + assert not params.mock_calls + action = SimpleNamespace(desiredCurvature=.001, desiredAcceleration=.5) + previous = SimpleNamespace(desiredCurvature=.002) + for speed in (0., .3, 5., 20.): + tau = seconds(speed, 0.) + assert tau == 0. + filter_action(action, previous, speed, tau) + assert vars(action) == {'desiredCurvature': .001, 'desiredAcceleration': .5} diff --git a/openpilot/starpilot/ui/feature_settings_owner.py b/openpilot/starpilot/ui/feature_settings_owner.py index 21f1895c86..62684360f8 100644 --- a/openpilot/starpilot/ui/feature_settings_owner.py +++ b/openpilot/starpilot/ui/feature_settings_owner.py @@ -198,11 +198,12 @@ class FeatureSettingsOwner: try: from opendbc.car.values import PLATFORMS from openpilot.starpilot.car.hyundai.aol import ioniq6_settings_capable + from openpilot.starpilot.car.ford.longitudinal_preferences import release_long_available as ford_release_long_available platform = PLATFORMS.get(str(cp.carFingerprint)) if (not self._galaxy() or platform is None or platform.__class__.__module__.split('.')[-2] != str(cp.brand) or not readable or release not in (None, b'0', b'1') or cp.notCar or cp.passive or cp.dashcamOnly or not (cp.alphaLongitudinalAvailable or ioniq6_settings_capable(cp)) or - release == b'1'): + (release == b'1' and not ford_release_long_available(cp))): return None return (str(cp.carFingerprint), str(cp.brand), int(cp.flags), int(cp.alternativeExperience), bool(cp.alphaLongitudinalAvailable), bool(cp.openpilotLongitudinalControl), bool(cp.pcmCruise), diff --git a/openpilot/starpilot/ui/tests/test_ford_release_alpha.py b/openpilot/starpilot/ui/tests/test_ford_release_alpha.py new file mode 100644 index 0000000000..4d991a9cfd --- /dev/null +++ b/openpilot/starpilot/ui/tests/test_ford_release_alpha.py @@ -0,0 +1,95 @@ +"""Both actual Developer panels expose only qualified Ford release opt-in.""" +from tempfile import TemporaryDirectory +from types import SimpleNamespace +from unittest.mock import patch +import pytest +from opendbc.car import gen_empty_fingerprint +from opendbc.car.ford.interface import CarInterface +from opendbc.car.ford.values import CAR +from openpilot.common.params import Params +from openpilot.starpilot.tests.ford_lifecycle_helpers import FORD_FACTORIES +from openpilot.starpilot.ui.tests.test_ioniq6_alpha_request import Toggle +from openpilot.selfdrive.ui.layouts.settings import developer as large +from openpilot.selfdrive.ui.mici.layouts.settings import developer as compact + + +def panel_for(module, cp, params): + ui = SimpleNamespace(CP=cp.as_reader(), params=params, is_release=True, engaged=False, has_longitudinal_control=False, + is_offroad=lambda: True, update_params=lambda: None) + layout = large.DeveloperLayout if module is large else compact.DeveloperLayoutMici + panel = layout.__new__(layout) + panel._alpha_long_toggle = Toggle() + panel._alpha_long_toggle.title, panel._alpha_long_toggle.description = 'Alpha Long', 'Existing alpha confirmation' + panel._long_maneuver_toggle = Toggle() + panel._lat_maneuver_toggle = Toggle() + if module is large: + panel._params, panel._is_release = params, True + for name in ('_joystick_toggle', '_adb_toggle', '_ssh_toggle', '_ui_debug_toggle', '_cellular_disabled_toggle'): + setattr(panel, name, Toggle()) + else: + panel._refresh_toggles = (('AlphaLongitudinalEnabled', panel._alpha_long_toggle),) + return panel, ui + + +def ford(identity, manual, offset): + fp = gen_empty_fingerprint() + if not manual: + fp[offset][0x5A] = 8 + fp[offset + 2].update({0x3D6: 8, 0x186: 8}) + return CarInterface.get_params(identity, fp, [], False, True, False) + + +@pytest.mark.parametrize('module', [large, compact]) +@pytest.mark.parametrize('identity,base', FORD_FACTORIES) +@pytest.mark.parametrize('manual,offset', [(False, 0), (True, 0), (False, 4), (True, 4)]) +def test_actual_release_panels_show_registered_ford_and_preserve_request(module, identity, base, manual, offset): + cp = ford(identity, manual, offset) + before = cp.to_dict() + with TemporaryDirectory() as directory: + params = Params(directory) + params.put_bool('AlphaLongitudinalEnabled', True, block=True) + panel, ui = panel_for(module, cp, params) + with patch.object(module, 'ui_state', ui): + panel._update_toggles() + assert panel._alpha_long_toggle.visible is True + cp.passive = True + panel._update_toggles() + assert panel._alpha_long_toggle.visible is False + assert params.get_bool('AlphaLongitudinalEnabled') + cp.passive = False + ui.CP = cp.as_reader().as_builder() + ui.CP.brand = 'hyundai' + panel._update_toggles() + assert panel._alpha_long_toggle.visible is False + assert cp.to_dict() == before + + +@pytest.mark.parametrize('module', [large, compact]) +@pytest.mark.parametrize('confirmation_state', ['valid', 'onroad', 'foreign']) +def test_actual_release_confirmation_rechecks_offroad_and_exact_ford_before_saving(module, confirmation_state): + cp = ford(CAR.FORD_MUSTANG_MACH_E_MK1, False, 4) + before = cp.to_dict() + with TemporaryDirectory() as directory: + params = Params(directory) + panel, ui = panel_for(module, cp, params) + callbacks = [] + def dialog(*args, **kwargs): + callbacks.append(kwargs['callback'] if module is large else args[0]) + return object() + confirmation = 'ConfirmDialog' if module is large else 'AlphaLongConfirmPage' + with patch.object(module, 'ui_state', ui), patch.object(module, confirmation, side_effect=dialog), \ + patch.object(module.gui_app, 'push_widget'), patch.object(panel, '_update_toggles'): + panel._on_alpha_long_enabled(True) + assert len(callbacks) == 1 and not params.get_bool('AlphaLongitudinalEnabled') + if confirmation_state == 'onroad': + ui.is_offroad = lambda: False + elif confirmation_state == 'foreign': + ui.CP = cp.as_reader().as_builder() + ui.CP.brand = 'hyundai' + if module is large: + callbacks[0](module.DialogResult.CONFIRM) + else: + with patch.object(module, 'restart_needed_callback'): + callbacks[0]() + assert params.get_bool('AlphaLongitudinalEnabled') == (confirmation_state == 'valid') + assert cp.to_dict() == before diff --git a/tools/ci/run_vehicle_tests.py b/tools/ci/run_vehicle_tests.py index de3bd57058..5992f797c3 100644 --- a/tools/ci/run_vehicle_tests.py +++ b/tools/ci/run_vehicle_tests.py @@ -24,6 +24,14 @@ from tools.ci import vehicle_pytest # Representative production callers plus their source/native refusal cases. # The emitted selected_test_ids record exact parametrized configurations. AOL_LIFECYCLE_TARGETS = ( + 'openpilot/starpilot/tests/test_ford_configuration_parity.py', + 'openpilot/starpilot/tests/test_ford_configuration_admission.py', + 'openpilot/starpilot/tests/test_ford_release_long_preferences.py', + 'openpilot/selfdrive/car/tests/test_registered_ford_discovery.py', + 'openpilot/starpilot/tests/test_ford_ci_registration.py', + 'opendbc_repo/opendbc/safety/tests/test_ford_configuration_parity.py', + 'opendbc_repo/opendbc/safety/tests/test_ford_alpha_aol.py', + 'opendbc_repo/opendbc/safety/tests/test_ford_full_engagement.py', 'opendbc_repo/opendbc/safety/tests/test_hkg_mode_parity.py', 'opendbc_repo/opendbc/safety/tests/test_pe_stopped_callers.py', 'opendbc_repo/opendbc/safety/tests/test_pe_combined_callers.py', @@ -122,6 +130,7 @@ AOL_LIFECYCLE_TARGETS = ( ) AOL_LIFECYCLE_SCOPE = { + "ford": "14 stock/Alpha identities, RELEASE CAN-FD Alpha, auto/manual, one/two Pandas; actual acquisition and caller/native lifecycle guards", "rivian": ("GEN1 base stock/DEBUG Alpha: actual factory/Card/SD/Controls/native engagement, brake LAT-only, gas, standstill zero output, " + "Reverse/Drive, temporary ToI recovery and physical UP_1 cancel; GEN2/unknown profiles refused"), "mazda": ("CX5 2022/CX9 2021 GEN1 stock AE32: engagement/brake/Cancel pause/toggle/Reverse/standstill/EPS recovery; " + @@ -158,6 +167,14 @@ DEBUG_SAFETY_CLASS_TARGETS = { RELEASE_TARGETS = ( + 'openpilot/starpilot/tests/test_ford_configuration_parity.py', + 'openpilot/starpilot/tests/test_ford_configuration_admission.py', + 'openpilot/starpilot/tests/test_ford_release_long_preferences.py', + 'openpilot/selfdrive/car/tests/test_registered_ford_discovery.py', + 'openpilot/starpilot/tests/test_ford_ci_registration.py', + 'opendbc_repo/opendbc/safety/tests/test_ford_configuration_parity.py', + 'opendbc_repo/opendbc/safety/tests/test_ford_alpha_aol.py', + 'opendbc_repo/opendbc/safety/tests/test_ford_full_engagement.py', 'opendbc_repo/opendbc/safety/tests/test_pe_combined_aol.py::TestPECombinedReleaseDenied', 'openpilot/starpilot/aol/tests/test_pe_combined_profile_contract.py', 'opendbc_repo/opendbc/safety/tests/test_palisade_stock_aol_status.py', @@ -267,7 +284,7 @@ RELEASE_TARGETS = ( "openpilot/starpilot/tests/test_ford_aol_intent.py", "opendbc_repo/opendbc/safety/tests/test_ford_aol.py", "opendbc_repo/opendbc/safety/tests/test_ford_mach_e_extended.py::TestFordMachEExtended", - "opendbc_repo/opendbc/safety/tests/test_ford_mach_e_extended.py::TestFordMachELongReleaseDenied", + "opendbc_repo/opendbc/safety/tests/test_ford_mach_e_extended.py::TestFordMachELongReleaseExtended", "opendbc_repo/opendbc/car/ford/tests/test_added_firmware_identity.py", "opendbc_repo/opendbc/car/ford/tests/test_required_source_health.py", "opendbc_repo/opendbc/car/ford/tests/test_blend_settings.py",