From 822dbe692f5083715d4154fa3ad51bfa48d3a3c4 Mon Sep 17 00:00:00 2001 From: firestar5683 <168790843+firestar5683@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:55:54 -0500 Subject: [PATCH] Keep automatic Ford discovery on the normal CAN harness --- opendbc_repo/opendbc/car/car_helpers.py | 10 +- opendbc_repo/opendbc/car/ford/discovery.py | 41 ++++++ .../tests/test_automatic_ford_discovery.py | 119 ++++++++++++++++++ prebuilt.json | 2 +- 4 files changed, 167 insertions(+), 5 deletions(-) create mode 100644 openpilot/selfdrive/car/tests/test_automatic_ford_discovery.py diff --git a/opendbc_repo/opendbc/car/car_helpers.py b/opendbc_repo/opendbc/car/car_helpers.py index ae8891d159..b768c4fa1c 100644 --- a/opendbc_repo/opendbc/car/car_helpers.py +++ b/opendbc_repo/opendbc/car/car_helpers.py @@ -11,7 +11,8 @@ from opendbc.car.fw_versions import (ObdCallback, get_fw_versions, get_fw_versio from opendbc.car.mock.values import CAR as MOCK from opendbc.car.gm.pedal_capability import automatic_bolt_candidate from opendbc.car.gm.discovery import automatic_volt_candidate -from opendbc.car.ford.discovery import normal_bus0_selection as ford_normal_bus0_selection, normal_bus0_queries as ford_normal_bus0_queries +from opendbc.car.ford.discovery import (normal_bus0_selection as ford_normal_bus0_selection, + normal_bus0_queries as ford_normal_bus0_queries, normal_bus_query_brand) from opendbc.car.hyundai.discovery import normal_bus_topology from opendbc.car.values import BRANDS from opendbc.car.vin import get_vin, is_valid_vin, VIN_UNKNOWN @@ -143,15 +144,16 @@ def fingerprint(can_recv: CanRecvCallable, can_send: CanSendCallable, set_obd_mu car_fw = get_fw_versions(can_recv, can_send, set_obd_multiplexing, query_brand='ford', normal_bus_only=True) ecu_rx_addrs = {(fw.responseAddress, fw.subAddress or None, fw.bus) for fw in car_fw if fw.responseAddress > 0} - elif normal_bus_topology(can_recv): + elif (normal_brand := normal_bus_query_brand(can_recv, normal_bus_topology)) is not None: set_obd_multiplexing(False) warmup_remaining = 0.1 - (time.monotonic() - start_time) if warmup_remaining > 0: time.sleep(warmup_remaining) - vin_rx_addr, vin_rx_bus, vin = get_vin(can_recv, can_send, (0, 1)) + vin_buses = (0,) if normal_brand == 'ford' else (0, 1) + vin_rx_addr, vin_rx_bus, vin = get_vin(can_recv, can_send, vin_buses) car_fw = get_fw_versions(can_recv, can_send, set_obd_multiplexing, - query_brand='hyundai', normal_bus_only=True) + query_brand=normal_brand, normal_bus_only=True) ecu_rx_addrs = {(fw.responseAddress, fw.subAddress or None, fw.bus) for fw in car_fw if fw.responseAddress > 0} else: # enable OBD multiplexing for VIN query diff --git a/opendbc_repo/opendbc/car/ford/discovery.py b/opendbc_repo/opendbc/car/ford/discovery.py index 16b79ac1fe..37e8e36237 100644 --- a/opendbc_repo/opendbc/car/ford/discovery.py +++ b/opendbc_repo/opendbc/car/ford/discovery.py @@ -1,4 +1,6 @@ """Normal-bus discovery for explicitly selected Ford CAN-FD vehicles.""" +import time + from opendbc.car.ford.values import CAR, FordFlags @@ -34,3 +36,42 @@ def normal_bus0_queries(): def normal_bus0_selection(candidate): return bool(candidate in CAR and CAR(candidate).config.flags & FordFlags.CANFD and not CAR(candidate).config.flags & FordFlags.LKA_STEERING and normal_bus0_queries()) + + +# Normal harness PT/chassis and stock-camera messages. This selects only a +# diagnostic query family; live VIN, ECU responses and fingerprinting still +# determine the vehicle and its control configuration. +NORMAL_BUS0_LAYOUT = {(0, 0x82): 8, (0, 0x165): 8, (0, 0x204): 8, + (0, 0x217): 8, (2, 0x3CA): 8, (2, 0x3D6): 8} + + +def normal_bus_query_brand(can_recv, hyundai_probe, *, clock=time.monotonic): + # Share the existing bounded passive HKG read, rather than consuming a second + # startup window or enabling the OBD mux to find out whether this is a Ford. + received = {} + malformed = False + reads = 0 + deadline = clock() + .12 + + def observe(**kwargs): + nonlocal malformed, reads + reads += 1 + packets = can_recv(**kwargs) + for packet in packets: + for frame in packet: + key = (frame.src, frame.address) + if key in NORMAL_BUS0_LAYOUT: + length = len(frame.dat) + malformed |= length != NORMAL_BUS0_LAYOUT[key] + received[key] = length + return packets + + if hyundai_probe(observe): + return 'hyundai' + # A Ford CAN1 frame can invalidate an HKG-only layout on the first read. + # Complete Ford evidence within the original time/read budget, not a new probe. + while not malformed and received != NORMAL_BUS0_LAYOUT and reads < 24 and clock() < deadline: + observe(wait_for_one=True) + if not malformed and received == NORMAL_BUS0_LAYOUT and normal_bus0_queries(): + return 'ford' + return None diff --git a/openpilot/selfdrive/car/tests/test_automatic_ford_discovery.py b/openpilot/selfdrive/car/tests/test_automatic_ford_discovery.py new file mode 100644 index 0000000000..1635ceba8b --- /dev/null +++ b/openpilot/selfdrive/car/tests/test_automatic_ford_discovery.py @@ -0,0 +1,119 @@ +"""Cold automatic Ford discovery must not put CAN2 on the unrelated OBD bus.""" +from unittest import mock + +import pytest + +from opendbc.car import car_helpers, structs +from opendbc.car.can_definitions import CanData +from opendbc.car.ford.discovery import normal_bus_query_brand +from opendbc.car.ford.values import CAR +from opendbc.car.hyundai.discovery import NORMAL_BUS_LAYOUT, normal_bus_topology +from openpilot.selfdrive.car.tests.test_registered_ford_discovery import FordEcus, VIN + +# Six original bus/address/DLC/payload samples from bfef.../00000003. +RECORDED = ( + (0, 130, '770814008b004000'), + (0, 357, '50c0000000000000'), + (0, 516, 'd1267d0000d10000'), + (0, 535, '268f268b26a226aa'), + (2, 970, '1e7d17f200000000'), + (2, 982, '051f7b8f9a067ffc'), +) + + +def recorded_frames(): + return [CanData(address, bytes.fromhex(data), bus) for bus, address, data in RECORDED] + + +def brand(frames): + return normal_bus_query_brand(lambda **kwargs: [frames], normal_bus_topology) + + +def test_recorded_normal_harness_selects_ford_queries_only(): + assert brand(recorded_frames()) == 'ford' + + +@pytest.mark.parametrize('missing', range(6)) +def test_partial_layout_does_not_choose_ford(missing): + frames = recorded_frames() + del frames[missing] + assert brand(frames) is None + + +@pytest.mark.parametrize('bad', range(6)) +def test_wrong_bus_dlc_and_returned_frames_do_not_choose_ford(bad): + for bus_delta, size in ((1, 8), (128, 8), (0, 7), (0, 12)): + frames = recorded_frames() + original = frames[bad] + frames[bad] = CanData(original.address, bytes(size), original.src + bus_delta) + assert brand(frames) is None + + +def test_conflicting_dlc_cannot_be_overwritten_by_a_good_copy(): + frames = recorded_frames() + assert brand([CanData(frames[0].address, bytes(7), frames[0].src), *frames]) is None + + +def test_existing_hyundai_and_unknown_layouts_keep_their_paths(): + frames = [CanData(address, bytes(length), bus) for (bus, address), length in NORMAL_BUS_LAYOUT.items()] + assert brand(frames) == 'hyundai' + assert brand([]) is None + assert brand([*frames, *recorded_frames()]) == 'hyundai' + + +def test_query_configuration_must_still_allow_normal_ford_bus(): + with mock.patch('opendbc.car.ford.discovery.normal_bus0_queries', return_value=False): + assert brand(recorded_frames()) is None + + +def test_shared_probe_uses_existing_read_bound(): + calls = [] + def recv(**kwargs): + calls.append(kwargs) + return [] + assert normal_bus_query_brand(recv, lambda recv: normal_bus_topology(recv, clock=lambda: 1.)) is None + assert len(calls) == 24 + assert all(call == {'wait_for_one': True} for call in calls) + + +@pytest.mark.parametrize('firmware_known', [False, True]) +def test_cold_automatic_uses_real_vin_isotp_fw_and_identity_matching(firmware_known): + firmware = {(0x706, b'\x22\xf1\x88'): b'RJ6T-14H102-BAE'.ljust(24, b'\0'), + (0x730, b'\x22\xf1\x88'): b'LJ9C-14D003-FA'.ljust(24, b'\0'), + (0x760, b'\x22\xf1\x88'): b'LK9C-2D053-CN'.ljust(24, b'\0'), + (0x764, b'\x22\xf1\x88'): b'ML3T-14D049-AL'.ljust(24, b'\0')} + if not firmware_known: + firmware = dict.fromkeys(firmware, b'unknown') + transport = FordEcus(firmware=firmware, background=recorded_frames()) + with mock.patch.dict(car_helpers.os.environ, {'FINGERPRINT': '', 'SKIP_FW_QUERY': '', 'DISABLE_FW_CACHE': ''}), \ + mock.patch.object(car_helpers.time, 'monotonic', side_effect=lambda: transport.clock), \ + mock.patch.object(car_helpers.time, 'sleep', side_effect=transport.sleep): + ci = car_helpers.get_car(transport.recv, transport.send, transport.mux, False, False) + assert transport.mux_calls and set(transport.mux_calls) == {False} + assert transport.sent and all(frame.src in (0, 4) for frame in transport.sent) + assert any(frame.dat[0] >> 4 == 3 for frame in transport.sent) + if firmware_known: + assert ci.CP.carFingerprint == CAR.FORD_MUSTANG_MACH_E_MK1 + assert ci.CP.fingerprintSource == structs.CarParams.FingerprintSource.fw + assert ci.CP.carVin == VIN + assert {(fw.address, fw.fwVersion) for fw in ci.CP.carFw} == {(address, data) for (address, _), data in firmware.items()} + else: + assert ci.CP.carFingerprint != CAR.FORD_MUSTANG_MACH_E_MK1 + assert not ci.CP.safetyConfigs or ci.CP.safetyConfigs[-1].safetyModel != structs.CarParams.SafetyModel.ford + + +def test_hyundai_mismatch_does_not_abort_remaining_ford_window(): + packets = iter([[CanData(0x1A0, bytes(8), 1)], recorded_frames()]) + def recv(**kwargs): + return [next(packets, [])] + assert normal_bus_query_brand(recv, normal_bus_topology) == 'ford' + + +def test_hyundai_early_exit_does_not_extend_outer_deadline(): + calls = [] + times = iter((1., 1.13)) + def recv(**kwargs): + calls.append(kwargs) + return [] + assert normal_bus_query_brand(recv, lambda recv: False, clock=lambda: next(times)) is None + assert not calls diff --git a/prebuilt.json b/prebuilt.json index 0e66f72b17..6850e33c3c 100644 --- a/prebuilt.json +++ b/prebuilt.json @@ -1 +1 @@ -{"protected_inventory_sha256": "d97d73289304727972ae534f32902cf5d207ea61af92426673df8a2fd4b6ed60", "schema_version": 1} +{"protected_inventory_sha256": "10eb839743b1c431389b7e65d78621ddc6794ba3d1b158b77d542b571423727f", "schema_version": 1}