diff --git a/opendbc/car/mazda/carcontroller.py b/opendbc/car/mazda/carcontroller.py index 259ad87dd30..76f974d5ab6 100644 --- a/opendbc/car/mazda/carcontroller.py +++ b/opendbc/car/mazda/carcontroller.py @@ -32,6 +32,7 @@ def __init__(self, dbc_names, CP, CP_SP): # scale and the non-delivery latch belong to the steer-to-zero firmware alone. self.eps_2022 = bool(CP.flags & MazdaFlags.EPS_HW) self.steer_to_zero = bool(CP.flags & MazdaFlags.STEER_TO_ZERO_EPS) + self.g46l = bool(CP.flags & MazdaFlags.G46L_RADAR) self.apply_torque_last = 0 self.driver_torque_samples: deque[float] = deque(maxlen=self.params.STEER_DRIVER_SAMPLES if self.eps_2022 else 1) self.sent_torque: deque[int] = deque(maxlen=self.params.STEER_ECHO_HISTORY if self.eps_2022 else 1) @@ -253,7 +254,7 @@ def update_longitudinal(self, CC, CC_SP, CS): if radar_master and self.frame % CarControllerParams.RADAR_STEP == 0: for bus in LONG_BUSES: - can_sends.extend(mazdacan.create_radar_frames(bus, self.radar_counter, self.lead_adv.lead)) + can_sends.extend(mazdacan.create_radar_frames(bus, self.radar_counter, self.lead_adv.lead, g46l=self.g46l)) self.radar_counter += 1 if radar_master and self.frame % CarControllerParams.LONG_STEP == 0: diff --git a/opendbc/car/mazda/fingerprints.py b/opendbc/car/mazda/fingerprints.py index d2af0dda7ec..cf55dcde383 100644 --- a/opendbc/car/mazda/fingerprints.py +++ b/opendbc/car/mazda/fingerprints.py @@ -67,6 +67,23 @@ b'SH51-21PS1-C\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', ], }, + CAR.MAZDA_CX5_KE: { + (Ecu.engine, 0x7e0, None): [ + b'PYAS-188K2-C\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', + ], + (Ecu.fwdRadar, 0x764, None): [ + b'G46L-67XA1-C\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', + ], + (Ecu.abs, 0x760, None): [ + b'KA0G-437AS-0-03\x00\x00\x00\x00\x00\x00\x00\x00\x00', + ], + (Ecu.fwdCamera, 0x706, None): [ + b'GMG6-67XK2-G\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', + ], + (Ecu.transmission, 0x7e1, None): [ + b'PYAS-21PS1-D\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', + ], + }, CAR.MAZDA_CX5: { (Ecu.eps, 0x730, None): [ b'K319-3210X-A-00\x00\x00\x00\x00\x00\x00\x00\x00\x00', diff --git a/opendbc/car/mazda/interface.py b/opendbc/car/mazda/interface.py index 00e2cf39d14..eb78500062c 100755 --- a/opendbc/car/mazda/interface.py +++ b/opendbc/car/mazda/interface.py @@ -1,11 +1,12 @@ #!/usr/bin/env python3 from opendbc.car import Bus, get_safety_config, structs +from opendbc.car.carlog import carlog from opendbc.car.common.conversions import Conversions as CV from opendbc.car.interfaces import CarInterfaceBase from opendbc.car.mazda.carcontroller import CarController from opendbc.car.mazda.carstate import CarState from opendbc.car.mazda.radar_interface import RadarInterface -from opendbc.car.mazda.values import CAR, DBC, LKAS_LIMITS, STEER_TO_ZERO_EPS_FW, MazdaFlags, MazdaSafetyFlags +from opendbc.car.mazda.values import CAR, DBC, G46L_RADAR_FW, LKAS_LIMITS, STEER_TO_ZERO_EPS_FW, MazdaFlags, MazdaSafetyFlags, platform_from_vin class CarInterface(CarInterfaceBase): @@ -18,7 +19,14 @@ def _get_params(ret: structs.CarParams, candidate, fingerprint, car_fw, alpha_lo ret.brand = "mazda" ret.safetyConfigs = [get_safety_config(structs.CarParams.SafetyModel.mazda)] - ret.radarUnavailable = Bus.radar not in DBC[candidate] + # The G46L is the one radar known never to publish 0x361-0x366 on bus 0: parsing its + # claimed bus would starve radarTracks behind a parser that never goes valid, so it runs + # vision-only. Any other radar keeps the platform's word — an unlisted newer revision of + # a working radar must not silently lose its tracks. + g46l_radar = any(fw.ecu == 'fwdRadar' and fw.fwVersion.rstrip(b'\x00') in G46L_RADAR_FW for fw in car_fw) + if g46l_radar: + ret.flags |= MazdaFlags.G46L_RADAR.value + ret.radarUnavailable = Bus.radar not in DBC[candidate] or g46l_radar # Every gen1 Mazda EPS is the same hardware; only the firmware differs. Steer-to-zero follows # the EPS firmware, so a donor-EPS swap carries it and older firmware in a 2022 body loses it. @@ -36,8 +44,12 @@ def _get_params(ret: structs.CarParams, candidate, fingerprint, car_fw, alpha_lo ret.flags |= MazdaFlags.LEGACY_FW_EPS.value ret.safetyConfigs[0].safetyParam |= MazdaSafetyFlags.LEGACY_FW_EPS.value - # Offer alpha longitudinal only with the EPS that retains lateral control through a stop. - ret.alphaLongitudinalAvailable = steer_to_zero and not ret.radarUnavailable + # Alpha-long silences the radar and stands in for it, so it needs the radar's dialect, + # not its tracks: offer it wherever the platform's radar speaks the 2022 family dialect + # (its DBC claims a radar bus) or the detected radar is the G46L whose own replay exists. + # The EPS gate stays: a stock older EPS cuts lateral below 45 kph, so stop-and-go would + # run unsteered. + ret.alphaLongitudinalAvailable = steer_to_zero and (Bus.radar in DBC[candidate] or g46l_radar) ret.openpilotLongitudinalControl = alpha_long and ret.alphaLongitudinalAvailable if ret.openpilotLongitudinalControl: ret.safetyConfigs[0].safetyParam |= MazdaSafetyFlags.LONG.value @@ -48,7 +60,12 @@ def _get_params(ret: structs.CarParams, candidate, fingerprint, car_fw, alpha_lo ret.longitudinalActuatorDelay = 0.36 # measured ~0.3 s dead time + ~0.3 s first-order lag # Older EPS firmware enforces hands-off and low-speed steering lockouts. - ret.dashcamOnly = candidate not in (CAR.MAZDA_CX5_2022, CAR.MAZDA_CX9_2021) and not steer_to_zero + # Docs mode carries no real EPS firmware, so leave dashcamOnly at the default. + if not docs: + ret.dashcamOnly = candidate not in (CAR.MAZDA_CX5_2022, CAR.MAZDA_CX9_2021) and not steer_to_zero + + carlog.debug({"event": "mazdaRadarVerdict", "radarUnavailable": ret.radarUnavailable, + "platformClaim": Bus.radar in DBC[candidate], "g46lRadar": g46l_radar, "steerToZeroEps": steer_to_zero}) ret.enableBsm = 0x477 in fingerprint[0] @@ -67,4 +84,11 @@ def _get_params_sp(stock_cp: structs.CarParams, ret: structs.CarParamsSP, candid car_fw: list[structs.CarParams.CarFw], alpha_long: bool, is_release_sp: bool, docs: bool) -> structs.CarParamsSP: ret.intelligentCruiseButtonManagementAvailable = True + # A carried-forward CarPlatformBundle can disagree with the physical car after a + # hardware swap or a branch switch without reinstall. + vin_platform = platform_from_vin(stock_cp.carVin) + if vin_platform is not None and vin_platform != str(candidate): + carlog.warning({"event": "platformBundleVinMismatch", "bundle": str(candidate), "vin_platform": vin_platform, + "hint": "the selected platform bundle does not match the VIN's platform"}) + return ret diff --git a/opendbc/car/mazda/mazdacan.py b/opendbc/car/mazda/mazdacan.py index f780ade1a59..103b94e3ce0 100644 --- a/opendbc/car/mazda/mazdacan.py +++ b/opendbc/car/mazda/mazdacan.py @@ -18,6 +18,10 @@ DIST_OBJ_SCALE = 0.0625 # m per bit, DIST_OBJ and RELV_OBJ share it DIST_OBJ_MAX = 255.875 # m, the full-scale DIST_OBJ reading a track can carry +# The G46L radar (2016.5 bodies) sends only this static frame and no track messages at +# all, so the lead rides CRZ_CTRL alone; fully static — no counter, no checksum. +G46L_RADAR_STATIC_MSG = (0x499, bytes.fromhex("0098400000000000")) + def crz_info_checksum(dat: bytes) -> int: # Invert the sum of the first seven bytes, excluding STOPPING and RESUME_UNLATCHING. @@ -82,8 +86,10 @@ def create_lead_track(d_rel: float, v_rel: float) -> bytes: return bytes(dat) -def create_radar_frames(bus, counter, lead): +def create_radar_frames(bus, counter, lead, g46l=False): """lead is the (dRel, vRel) of the object to advertise on 0x364, or None for an empty slot.""" + if g46l: + return [CanData(G46L_RADAR_STATIC_MSG[0], G46L_RADAR_STATIC_MSG[1], bus)] frames = [CanData(RADAR_STATIC_MSG[0], RADAR_STATIC_MSG[1], bus)] for addr, dat in RADAR_TRACK_MSGS.items(): if lead is not None and addr == LEAD_TRACK_ADDR: diff --git a/opendbc/car/mazda/tests/test_mazda_interface.py b/opendbc/car/mazda/tests/test_mazda_interface.py index 8fce8512365..1775687ea11 100644 --- a/opendbc/car/mazda/tests/test_mazda_interface.py +++ b/opendbc/car/mazda/tests/test_mazda_interface.py @@ -12,8 +12,9 @@ from opendbc.car import Bus, structs from opendbc.car.common.conversions import Conversions as CV from opendbc.car.mazda.carcontroller import CarController +from opendbc.car.mazda.fingerprints import FW_VERSIONS from opendbc.car.mazda.tests.conftest import DBC_NAME, car_params, car_params_sp -from opendbc.car.mazda.values import CAR, DBC, LKAS_LIMITS, STEER_TO_ZERO_EPS_FW, MazdaFlags, MazdaSafetyFlags +from opendbc.car.mazda.values import CAR, DBC, G46L_RADAR_FW, LKAS_LIMITS, STEER_TO_ZERO_EPS_FW, MazdaFlags, MazdaSafetyFlags Ecu = structs.CarParams.Ecu @@ -36,6 +37,21 @@ def eps_fw(version: bytes) -> list[structs.CarParams.CarFw]: return [fw] +def radar_fw(version: bytes) -> structs.CarParams.CarFw: + fw = structs.CarParams.CarFw() + fw.ecu = Ecu.fwdRadar + fw.address = 0x764 + fw.subAddress = 0 + fw.fwVersion = version + return fw + + +# padded to the 24-byte fw field the UDS query returns; the padding length is +# load-bearing — a longer test padding once masked an exact-match miss +_g46l_stem = sorted(G46L_RADAR_FW)[0] +G46L_FW = _g46l_stem + b'\x00' * (24 - len(_g46l_stem)) + + class TestMazdaEpsSwap: """A 2022+ CX-5 EPS swapped into an older Mazda brings the EPS-derived behavior with it. @@ -64,11 +80,11 @@ def test_swapped_eps_unlocks_longitudinal(self): assert not car_params(CAR.MAZDA_CX5, alpha_long=True).alphaLongitudinalAvailable def test_swapped_eps_keeps_the_real_vehicle_specs(self): - # EPS detection must not replace the chassis-specific physical parameters. + # EPS detection must not replace the chassis-specific physical parameters. steerRatio + # no longer separates the platforms: the pre-2022 racks run the 2022's 18.1 too. swapped = car_params(CAR.MAZDA_CX5, car_fw=eps_fw(SWAPPED_EPS_FW)) cx5_2022 = car_params(CAR.MAZDA_CX5_2022) assert swapped.mass != cx5_2022.mass - assert swapped.steerRatio != cx5_2022.steerRatio assert swapped.tireStiffnessFactor != cx5_2022.tireStiffnessFactor def test_supported_platforms_are_unchanged(self): @@ -103,6 +119,21 @@ def test_alpha_long_follows_the_eps(self, candidate, swapped): def test_stock_long_still_reads_the_radar_tracks(self): assert not car_params(CAR.MAZDA_CX9_2021, alpha_long=True).radarUnavailable + def test_ke_runs_vision_only_under_a_swapped_eps(self): + # the first-generation radar speaks no track dialect, so the platform promises no + # radar bus: the lead comes from the model and no teardown is offered, while the + # EPS swap still lifts the steering lockouts + stock = car_params(CAR.MAZDA_CX5_KE) + assert stock.radarUnavailable + assert stock.dashcamOnly + + swapped = car_params(CAR.MAZDA_CX5_KE, car_fw=eps_fw(SWAPPED_EPS_FW)) + assert swapped.radarUnavailable + assert not swapped.dashcamOnly + assert swapped.minSteerSpeed == 0 + assert swapped.steerActuatorDelay == pytest.approx(0.14, abs=5e-8) + assert not swapped.alphaLongitudinalAvailable + @pytest.mark.parametrize("candidate, car_fw, alpha_long, expected", [ (CAR.MAZDA_CX5_2022, None, False, True), (CAR.MAZDA_CX5_2022, None, True, True), @@ -124,13 +155,15 @@ def test_safety_param_follows_the_eps(self, candidate, car_fw, alpha_long, expec # longitudinal keeps its own bit assert bool(CP.safetyConfigs[0].safetyParam & MazdaSafetyFlags.LONG.value) == CP.openpilotLongitudinalControl - @pytest.mark.parametrize("candidate", [CAR.MAZDA_CX5, CAR.MAZDA_CX9, CAR.MAZDA_3, CAR.MAZDA_6]) + @pytest.mark.parametrize("candidate", [CAR.MAZDA_CX5_KE, CAR.MAZDA_CX5, CAR.MAZDA_CX9, CAR.MAZDA_3, CAR.MAZDA_6]) def test_docs_are_generated_without_firmware(self, candidate): - # car_fw is empty when building CARS.md, so the docs must keep advertising dashcam mode + # car_fw is empty in docs mode, and the car picker consumes docs mode: a firmware-gated + # platform must stay selectable there. dashcamOnly is a measured-hardware call, so the + # on-device EPS check keeps the gate; docs describe the stock car from the platform table. from opendbc.car import gen_empty_fingerprint from opendbc.car.mazda.interface import CarInterface CP = CarInterface.get_params(candidate, gen_empty_fingerprint(), [], alpha_long=False, is_release=False, docs=True) - assert CP.dashcamOnly + assert not CP.dashcamOnly class TestMazdaLegacyFwEps: @@ -209,6 +242,75 @@ def test_legacy_firmware_is_listed_for_the_2022_body_only(self): assert LEGACY_FW_EPS not in STEER_TO_ZERO_EPS_FW assert set(FW_VERSIONS[CAR.MAZDA_CX5_2022][(Ecu.eps, 0x730, None)]) == STEER_TO_ZERO_EPS_FW | {LEGACY_FW_EPS} +class TestForeignRadar: + """The G46L is the one radar known never to publish 0x361-0x366 on bus 0. + + A carried-forward platform bundle can claim a radar bus the physical car cannot fill; + parsing that bus would starve radarTracks behind a parser that never goes valid. The + G46L gets the vision-only path, and alpha-long keys on the radar's dialect, not its + tracks (mazdacan.py replays the G46L's own). Every other radar keeps the platform's + word, so an unlisted newer revision of a working radar loses nothing. + """ + + def test_g46l_runs_vision_only_behind_a_radar_claim(self): + # the support-ticket car: a 2016 KE body with the swapped 2022 EPS, forced to the + # CX-5 2022 platform by a carried-forward bundle. The G46L answers the fw query but + # never sends 0x361-0x366, so parsing its bus would starve radarTracks forever + CP = car_params(CAR.MAZDA_CX5_2022, car_fw=[radar_fw(G46L_FW)]) + assert CP.radarUnavailable + assert CP.alphaLongitudinalAvailable + assert CP.flags & MazdaFlags.G46L_RADAR + + def test_g46l_unlocks_alpha_long_on_a_platform_without_a_radar_bus(self): + # the same car on its own platform: no radar bus claimed, but the G46L is reachable + # and its dialect can be replayed, so the port is offered with the swapped EPS + fw = eps_fw(SWAPPED_EPS_FW) + [radar_fw(G46L_FW)] + CP = car_params(CAR.MAZDA_CX5_KE, car_fw=fw, alpha_long=True) + assert CP.radarUnavailable + assert not CP.dashcamOnly + assert CP.alphaLongitudinalAvailable + assert CP.openpilotLongitudinalControl + assert bool(CP.safetyConfigs[0].safetyParam & MazdaSafetyFlags.LONG.value) + + # a stock EPS keeps the offer off even with the G46L present + stock = car_params(CAR.MAZDA_CX5_KE, car_fw=[radar_fw(G46L_FW)], alpha_long=True) + assert not stock.alphaLongitudinalAvailable + + def test_an_unknown_radar_keeps_the_stock_parse_path(self): + # an unlisted newer revision of a working radar must not silently lose its tracks: + # only the G46L is known not to publish them, so everything else keeps the claim + unknown = [radar_fw(b'KK00-67X00-A' + b'\x00' * 16)] + claiming = car_params(CAR.MAZDA_CX5_2022, car_fw=unknown) + assert not claiming.radarUnavailable + assert claiming.alphaLongitudinalAvailable + + non_claiming = car_params(CAR.MAZDA_CX5_KE, car_fw=eps_fw(SWAPPED_EPS_FW) + unknown, alpha_long=True) + assert non_claiming.radarUnavailable + assert not non_claiming.alphaLongitudinalAvailable + assert not non_claiming.openpilotLongitudinalControl + + def test_silent_radar_keeps_the_platform_claim(self): + # a fw query that never reached the radar must not flip a claiming platform into + # vision-only + CP = car_params(CAR.MAZDA_CX5_2022, car_fw=eps_fw(SWAPPED_EPS_FW)) + assert not CP.radarUnavailable + + def test_g46l_flag_bit_is_unshared(self): + # single-bit members sharing the G46L bit must be its own flag alone: the rebase onto + # the danger-unstable promotion once left LEGACY_FW_EPS and G46L_RADAR both on bit 4, + # and every legacy-firmware car read as carrying the G46L dialect + sharers = [m.name for m in MazdaFlags if m.value & MazdaFlags.G46L_RADAR and bin(m.value).count('1') == 1] + assert sharers == ['G46L_RADAR'] + + def test_the_platforms_own_radar_fw_stays_parsed(self): + # only the G46L set disables parsing: the CX-5 2022's own radar firmware must keep + # the track path + own_fw = sorted(FW_VERSIONS[CAR.MAZDA_CX5_2022][(Ecu.fwdRadar, 0x764, None)])[0] + CP = car_params(CAR.MAZDA_CX5_2022, car_fw=[radar_fw(own_fw)]) + assert not CP.radarUnavailable + assert CP.alphaLongitudinalAvailable + assert not CP.flags & MazdaFlags.G46L_RADAR + def test_non_gen1_platform_refused_at_admission(): # one init-time check instead of per-frame guards in the message builders, which every diff --git a/opendbc/car/mazda/tests/test_mazda_mazdacan.py b/opendbc/car/mazda/tests/test_mazda_mazdacan.py index c9ffd1f27bc..a7bb0c813d2 100644 --- a/opendbc/car/mazda/tests/test_mazda_mazdacan.py +++ b/opendbc/car/mazda/tests/test_mazda_mazdacan.py @@ -128,6 +128,13 @@ def test_radar_frames_counter_and_lead_track(): assert tracks[0x364] == "0a4e00001c00000f" +def test_g46l_radar_frames_are_the_static_capture_alone(): + # the G46L never sends track messages, lead or not: the lead rides CRZ_CTRL alone + for lead in (None, (10.25, 0.)): + frames = mazdacan.create_radar_frames(0, 15, lead, g46l=True) + assert [(f.address, f.dat.hex(), f.src) for f in frames] == [(0x499, "0098400000000000", 0)] + + def test_lead_track_constant_bytes_match_the_stock_release_capture(): # the template's measurement fields are zeroed, so a zero-range lead reproduces it exactly assert mazdacan.create_lead_track(0., 0.) == mazdacan.LEAD_TRACK_TEMPLATE diff --git a/opendbc/car/mazda/tests/test_mazda_vin.py b/opendbc/car/mazda/tests/test_mazda_vin.py index e36fa3459ed..4388ed3a81e 100644 --- a/opendbc/car/mazda/tests/test_mazda_vin.py +++ b/opendbc/car/mazda/tests/test_mazda_vin.py @@ -9,7 +9,7 @@ from opendbc.car import structs from opendbc.car.fw_versions import match_fw_to_car from opendbc.car.mazda.fingerprints import FW_VERSIONS -from opendbc.car.mazda.values import CAR, STEER_TO_ZERO_EPS_FW, match_fw_to_car_fuzzy +from opendbc.car.mazda.values import CAR, STEER_TO_ZERO_EPS_FW, match_fw_to_car_fuzzy, platform_from_vin from opendbc.car.vin import VIN_UNKNOWN # a steer-to-zero EPS a swap donates; the CX-5 2022 list also carries legacy firmware now @@ -66,13 +66,26 @@ def test_real_listing_vins(self, vin, expected): expected_platforms = {str(expected)} if expected is not None else set() assert match_fw_to_car_fuzzy({}, vin, FW_VERSIONS) == expected_platforms + def test_the_support_log_vin_resolves(self): + # the KE body from the alpha-long support log, running behind a carried-forward + # CX-5 2022 platform bundle: the resolver must name its own platform + assert platform_from_vin('JM3KE4DYXG0877243') == str(CAR.MAZDA_CX5_KE) + + @pytest.mark.parametrize("vin, expected", REAL_VINS) + def test_platform_from_vin_matches_the_fuzzy_matcher(self, vin, expected): + assert platform_from_vin(vin) == (str(expected) if expected is not None else None) + + def test_platform_from_vin_rejects_unknown(self): + assert platform_from_vin(VIN_UNKNOWN) is None + assert platform_from_vin('JM3KE4DYXG08772') is None # short + def test_wrong_wmi_does_not_match(self): assert match_fw_to_car_fuzzy({}, make_vin('JM6', 'TC', 'M'), FW_VERSIONS) == set() @pytest.mark.parametrize("wmi, chassis_code, year_code", [ ('JM1', 'BP', 'K'), # Mazda 3 2019+ ('JM3', 'DM', 'N'), # CX-30 - ('JM3', 'KE', 'H'), # pre-2017 CX-5 + ('JM3', 'KE', 'H'), # a CX-5 KE past the last supported model year ('7MM', 'VA', 'P'), # CX-50 ('JM3', 'TC', 'T'), # a CX-9 past the last supported model year ]) @@ -96,6 +109,11 @@ def test_every_vin_field_is_required(self): assert match_fw_to_car_fuzzy({}, make_vin('JM3', 'KF', 'G'), FW_VERSIONS) == set() # 2016 KF predates the port assert match_fw_to_car_fuzzy({}, make_vin('JM3', 'KF', 'N'), FW_VERSIONS) == {str(CAR.MAZDA_CX5_2022)} + @pytest.mark.parametrize("year_code", ['C', 'D', 'E', 'F', 'G']) + def test_ke_model_years_name_the_ke_platform(self, year_code): + # the first-generation CX-5, one platform across its whole 2012-16 run + assert match_fw_to_car_fuzzy({}, make_vin('JM3', 'KE', year_code), FW_VERSIONS) == {str(CAR.MAZDA_CX5_KE)} + def test_engine_firmware_alone_is_not_evidence_without_a_decodable_vin(self): # an Oceania export VIN (real report): no model year, no known WMI; the # engine is the only responding firmware in the database. One recognised @@ -217,6 +235,18 @@ def test_stock_fw_set_still_exact_matches(self): assert exact_match assert matches == {str(CAR.MAZDA_CX9_2021)} + def test_reported_ke_exact_matches_on_its_body_ecus(self): + # the 2016.5 report: a 2022 CX-5 EPS swap riding on first-generation body ECUs. + # The swap firmware stays under MAZDA_CX5_2022; the body ECUs alone exact-match + # the chassis, and no EPS entry exists to contradict the swap + donor_eps = FW_VERSIONS[CAR.MAZDA_CX5_2022][(Ecu.eps, 0x730, None)][1] # KSD5, the reported swap + car_fw = [_car_fw(ecu, addr, versions[0]) + for (ecu, addr, _), versions in FW_VERSIONS[CAR.MAZDA_CX5_KE].items()] + car_fw.append(_car_fw(Ecu.eps, 0x730, donor_eps)) + exact_match, matches = match_fw_to_car(car_fw, make_vin('JM3', 'KE', 'G')) + assert exact_match + assert matches == {str(CAR.MAZDA_CX5_KE)} + def test_oceania_eps_swap_matches_on_the_engine_behind_the_donor_eps(self): # the reported car: Oceania VIN (never decodes), donor EPS, and chassis ECUs # unknown to the North American database. Two recognised ECUs: the engine @@ -291,3 +321,17 @@ def test_known_vins_still_match_through_the_real_matcher(self, vin, expected): else: assert not exact_match assert matches == {str(expected)}, vin + + def test_ke_names_by_vin_through_a_donor_eps(self): + # a KE with dealer-updated body ECUs still names by VIN through the swap; the + # donor EPS is the one ECU the database knows + donor_eps = FW_VERSIONS[CAR.MAZDA_CX5_2022][(Ecu.eps, 0x730, None)][0] + car_fw = [ + _car_fw(Ecu.eps, 0x730, donor_eps), + _car_fw(Ecu.engine, 0x7e0, UNKNOWN_ENGINE_FW), + _car_fw(Ecu.abs, 0x760, UNKNOWN_ABS_FW), + _car_fw(Ecu.transmission, 0x7e1, UNKNOWN_TRANS_FW), + ] + exact_match, matches = match_fw_to_car(car_fw, make_vin('JM3', 'KE', 'G')) + assert not exact_match + assert matches == {str(CAR.MAZDA_CX5_KE)} diff --git a/opendbc/car/mazda/values.py b/opendbc/car/mazda/values.py index b38cf56dcd8..5aca6ddfb65 100644 --- a/opendbc/car/mazda/values.py +++ b/opendbc/car/mazda/values.py @@ -170,6 +170,9 @@ class MazdaFlags(IntFlag): # Everything keyed on the measured hardware rather than on what the firmware permits. EPS_HW = STEER_TO_ZERO_EPS | LEGACY_FW_EPS + # The G46L radar's dialect bit; see G46L_RADAR_FW below. + G46L_RADAR = 8 + class MazdaSafetyFlags(IntFlag): LONG = 1 @@ -197,9 +200,16 @@ class MazdaPlatformConfig(PlatformConfig): class CAR(Platforms): + MAZDA_CX5_KE = MazdaPlatformConfig( + [MazdaCarDocs("Mazda CX-5 2012-16")], + MazdaCarSpecs(mass=3433 * CV.LB_TO_KG, wheelbase=2.7, steerRatio=18.1), # steer ratio from the 2022 CX-5: same rack hardware + # This radar does not publish 0x361-0x366 tracks on bus 0. + dbc_dict={Bus.pt: 'mazda_2017'}, + wmis={WMI.JAPAN_CROSSOVER}, chassis_codes={'KE'}, years={'C', 'D', 'E', 'F', 'G'}, # 2012-16 + ) MAZDA_CX5 = MazdaPlatformConfig( [MazdaCarDocs("Mazda CX-5 2017-21")], - MazdaCarSpecs(mass=3655 * CV.LB_TO_KG, wheelbase=2.7, steerRatio=15.5), + MazdaCarSpecs(mass=3655 * CV.LB_TO_KG, wheelbase=2.7, steerRatio=18.1), # steer ratio from the 2022 CX-5: same rack hardware wmis={WMI.JAPAN_CROSSOVER}, chassis_codes={'KF'}, years={'H', 'J', 'K', 'L', 'M'}, # 2017-21 ) MAZDA_CX9 = MazdaPlatformConfig( @@ -243,6 +253,13 @@ class LKAS_LIMITS: b'KSD5-3210X-C-00\x00\x00\x00\x00\x00\x00\x00\x00\x00', } +# The 2016.5-era radar kept by an EPS-swapped older body. Listed for fingerprinting, but +# it never publishes 0x361-0x366 on bus 0; its one frame is fully static — no counter, no +# checksum. Stored unpadded; matched with nulls stripped so UDS padding cannot break it. +G46L_RADAR_FW = { + b'G46L-67XA1-C', +} + class Buttons: NONE = 0 @@ -252,27 +269,34 @@ class Buttons: CANCEL = 4 -def match_fw_to_car_fuzzy(live_fw_versions, vin, offline_fw_versions) -> set[str]: - # After firmware matching fails, require VIN fields to identify one chassis platform. +def platform_from_vin(vin: str) -> str | None: + """The one platform the VIN's fields identify, or None when the VIN is unknown to + every platform or ambiguous. + + Shared by the fuzzy firmware fallback and the selected-car/VIN mismatch warning. + """ if not is_valid_vin(vin): - return set() + return None vin_obj = Vin(vin) chassis_code = vin_obj.vds[0:2] year = vin_obj.vis[0] - candidates = set() - for platform in CAR: - platform_config = platform.config - if vin_obj.wmi in platform_config.wmis and chassis_code in platform_config.chassis_codes and year in platform_config.years: - candidates.add(platform) + candidates = {platform for platform in CAR + if vin_obj.wmi in platform.config.wmis and chassis_code in platform.config.chassis_codes + and year in platform.config.years} + return str(next(iter(candidates))) if len(candidates) == 1 else None - if len(candidates) == 1: - carlog.error(f"Fingerprinted {next(iter(candidates))} by VIN") - return {str(c) for c in candidates} + +def match_fw_to_car_fuzzy(live_fw_versions, vin, offline_fw_versions) -> set[str]: + # After firmware matching fails, require VIN fields to identify one chassis platform. + platform = platform_from_vin(vin) + if platform is not None: + carlog.error(f"Fingerprinted {platform} by VIN") + return {platform} # Only export VINs without model-year data continue to the EPS-swap fallback. - if vin_obj.wmi != WMI.OCEANIA_EXPORT: + if not is_valid_vin(vin) or Vin(vin).wmi != WMI.OCEANIA_EXPORT: return set() # Export-car swaps require a recognized EPS and an engine that identifies one platform. diff --git a/opendbc/car/tests/routes.py b/opendbc/car/tests/routes.py index 91d19a10f23..9ec9f012c49 100644 --- a/opendbc/car/tests/routes.py +++ b/opendbc/car/tests/routes.py @@ -54,6 +54,9 @@ GM.CHEVROLET_TRAILBLAZER_NON_ACC_2ND_GEN, GM.CHEVROLET_MALIBU_NON_ACC_9TH_GEN, GM.CADILLAC_XT5_NON_ACC_1ST_GEN, + + # no public route yet, the 2016.5 swap car is the only known device-validated KE + MAZDA.MAZDA_CX5_KE, ] diff --git a/opendbc/car/torque_data/speed_dependent.toml b/opendbc/car/torque_data/speed_dependent.toml index 22086546c7a..e4ad46799b5 100644 --- a/opendbc/car/torque_data/speed_dependent.toml +++ b/opendbc/car/torque_data/speed_dependent.toml @@ -22,3 +22,14 @@ seed_version = 1 speed_bp = [6.5, 9.5, 12.0, 16.4, 21.0, 28.0, 35.0] laf_bp = [2.67, 2.70, 2.56, 1.53, 1.28, 1.72, 1.97] friction_bp = [0.161, 0.154, 0.116, 0.163, 0.136, 0.128, 0.108] + +[MAZDA_CX5_KE] +# Apply these values only to a KE with a detected CX-5 2022 donor EPS. The stock EPS +# uses a flat 800-count scale and a 45 kph steering floor. +requires_steer_to_zero = true +# The CX-5 2022 table copied unchanged: the donor EPS is the EPS those bins were learned +# under, and no KE has its own fit yet. seed_version mirrors the source table. +speed_bp = [6.5, 9.5, 12.0, 16.4, 21.0, 28.0, 35.0] +laf_bp = [2.42, 2.86, 2.29, 1.21, 1.22, 1.53, 1.76] +friction_bp = [0.160, 0.143, 0.124, 0.145, 0.133, 0.122, 0.108] +seed_version = 1 diff --git a/opendbc/car/torque_data/substitute.toml b/opendbc/car/torque_data/substitute.toml index 945ab359688..1b217568e52 100644 --- a/opendbc/car/torque_data/substitute.toml +++ b/opendbc/car/torque_data/substitute.toml @@ -3,6 +3,7 @@ legend = ["LAT_ACCEL_FACTOR", "MAX_LAT_ACCEL_MEASURED", "FRICTION"] "MAZDA_6" = "MAZDA_CX9_2021" "MAZDA_CX5" = "MAZDA_CX9_2021" "MAZDA_CX5_2022" = "MAZDA_CX9_2021" +"MAZDA_CX5_KE" = "MAZDA_CX9_2021" "MAZDA_CX9" = "MAZDA_CX9_2021" "DODGE_DURANGO" = "CHRYSLER_PACIFICA_2020" diff --git a/opendbc/safety/modes/mazda.h b/opendbc/safety/modes/mazda.h index f977fe5f7c1..f7659079c2e 100644 --- a/opendbc/safety/modes/mazda.h +++ b/opendbc/safety/modes/mazda.h @@ -59,11 +59,17 @@ static bool mazda_radar_was_silenced = false; // Pin replaced-radar traffic to captured stock patterns where possible. +// Each radar generation sends its own static capture; the controller picks the dialect (mazdacan.py) static bool mazda_radar_static_msg_valid(const CANPacket_t *msg) { - return (msg->data[0] == 0x00U) && (msg->data[1] == 0x08U) && - (msg->data[2] == 0xc0U) && (msg->data[3] == 0x00U) && - (msg->data[4] == 0x00U) && (msg->data[5] == 0x00U) && - (msg->data[6] == 0x00U) && (msg->data[7] == 0x00U); + bool capture_2022 = (msg->data[0] == 0x00U) && (msg->data[1] == 0x08U) && + (msg->data[2] == 0xc0U) && (msg->data[3] == 0x00U) && + (msg->data[4] == 0x00U) && (msg->data[5] == 0x00U) && + (msg->data[6] == 0x00U) && (msg->data[7] == 0x00U); + bool capture_g46l = (msg->data[0] == 0x00U) && (msg->data[1] == 0x98U) && + (msg->data[2] == 0x40U) && (msg->data[3] == 0x00U) && + (msg->data[4] == 0x00U) && (msg->data[5] == 0x00U) && + (msg->data[6] == 0x00U) && (msg->data[7] == 0x00U); + return capture_2022 || capture_g46l; } static bool mazda_empty_radar_track_msg_valid(const CANPacket_t *msg) { diff --git a/opendbc/safety/tests/test_mazda.py b/opendbc/safety/tests/test_mazda.py index 42948438c41..6802ca90fae 100755 --- a/opendbc/safety/tests/test_mazda.py +++ b/opendbc/safety/tests/test_mazda.py @@ -435,6 +435,18 @@ def test_empty_radar_tracks_allowed(self): for addr, dat in radar_messages.items(): self.assertTrue(self._tx(common.make_msg(bus, addr, 8, dat))) + def test_g46l_radar_static_allowed(self): + # the 2016.5 G46L body's own static capture; the 2022 one above is not its frame, and + # the G46L never sends track messages at all + for controls_allowed in (False, True): + self.safety.set_controls_allowed(controls_allowed) + for bus in (0, 2): + self.assertTrue(self._tx(common.make_msg(bus, 0x499, 8, bytes.fromhex("0098400000000000")))) + + self.safety.set_controls_allowed(True) + for bus in (0, 2): + self.assertFalse(self._tx(common.make_msg(bus, 0x499, 8, bytes.fromhex("0098400100000000")))) + def test_synthetic_lead_radar_track_allowed_disengaged(self): # Permit the measurement fields while requiring the occupied-track template. The slot is # perception, not actuation, so it remains valid with controls_allowed low like stock radar diff --git a/opendbc/sunnypilot/car/car_list.json b/opendbc/sunnypilot/car/car_list.json index 536c4258280..f92d1b3582c 100644 --- a/opendbc/sunnypilot/car/car_list.json +++ b/opendbc/sunnypilot/car/car_list.json @@ -2800,6 +2800,58 @@ ], "package": "Adaptive Cruise Control (ACC) & Lane Assist" }, + "Mazda 3 2017-18": { + "platform": "MAZDA_3", + "make": "Mazda", + "brand": "mazda", + "model": "3", + "year": [ + "2017", + "2018" + ], + "package": "All" + }, + "Mazda 6 2017-20": { + "platform": "MAZDA_6", + "make": "Mazda", + "brand": "mazda", + "model": "6", + "year": [ + "2017", + "2018", + "2019", + "2020" + ], + "package": "All" + }, + "Mazda CX-5 2012-16": { + "platform": "MAZDA_CX5_KE", + "make": "Mazda", + "brand": "mazda", + "model": "CX-5", + "year": [ + "2012", + "2013", + "2014", + "2015", + "2016" + ], + "package": "All" + }, + "Mazda CX-5 2017-21": { + "platform": "MAZDA_CX5", + "make": "Mazda", + "brand": "mazda", + "model": "CX-5", + "year": [ + "2017", + "2018", + "2019", + "2020", + "2021" + ], + "package": "All" + }, "Mazda CX-5 2022-25": { "platform": "MAZDA_CX5_2022", "make": "Mazda", @@ -2813,6 +2865,20 @@ ], "package": "All" }, + "Mazda CX-9 2016-20": { + "platform": "MAZDA_CX9", + "make": "Mazda", + "brand": "mazda", + "model": "CX-9", + "year": [ + "2016", + "2017", + "2018", + "2019", + "2020" + ], + "package": "All" + }, "Mazda CX-9 2021-23": { "platform": "MAZDA_CX9_2021", "make": "Mazda", diff --git a/opendbc/sunnypilot/car/tests/test_speed_dep_config.py b/opendbc/sunnypilot/car/tests/test_speed_dep_config.py index a50b5712e99..2421499fe64 100644 --- a/opendbc/sunnypilot/car/tests/test_speed_dep_config.py +++ b/opendbc/sunnypilot/car/tests/test_speed_dep_config.py @@ -8,7 +8,7 @@ from opendbc.car import gen_empty_fingerprint from opendbc.car.mazda.interface import CarInterface -from opendbc.car.mazda.values import CAR, MazdaFlags +from opendbc.car.mazda.values import CAR, STEER_TO_ZERO_EPS_FW, MazdaFlags from opendbc.car.structs import CarParams from opendbc.sunnypilot.car.interfaces import (get_speed_dep_config, get_speed_dep_config_for_car, get_steer_max_schedule, get_steer_rail_schedule, get_steer_slew_schedule) @@ -23,6 +23,18 @@ def cx5_2022_cp() -> CarParams: return cp +def ke_swapped_cp() -> CarParams: + # the 2016.5 report: a KE body with the 2022 CX-5 EPS the seed bins were learned under + fw = CarParams.CarFw() + fw.ecu = CarParams.Ecu.eps + fw.address = 0x730 + fw.subAddress = 0 + fw.fwVersion = sorted(STEER_TO_ZERO_EPS_FW)[0] + cp = CarInterface.get_params(CAR.MAZDA_CX5_KE, gen_empty_fingerprint(), [fw], alpha_long=False, is_release=False, docs=False) + assert cp.flags & MazdaFlags.STEER_TO_ZERO_EPS and cp.minSteerSpeed == 0.0 + return cp + + def brand_cp(brand: str, fingerprint: str = "", min_steer_speed: float = 0.0) -> CarParams: # a minimal CarParams for the brands the schedule lookup has to decline cp = CarParams() @@ -84,6 +96,19 @@ def test_flagged_entry_stays_empty_behind_a_floor(self, monkeypatch): monkeypatch.setattr(mod, 'get_speed_dep_config', lambda: {'MAZDA_CX9_2021': {'requires_steer_to_zero': True, 'speed_bp': [30.0]}}) assert get_speed_dep_config_for_car(brand_cp(**STOCK_MAZDA)) == {} + def test_ke_seeds_apply_under_the_donor_eps(self): + # the tables start identical: the donor EPS is the EPS the CX-5 2022 bins were + # learned under, and the swap carries the same STEER_MAX schedule with them + ke = get_speed_dep_config_for_car(ke_swapped_cp()) + cx5_2022 = get_speed_dep_config_for_car(cx5_2022_cp()) + for key in ('speed_bp', 'laf_bp', 'friction_bp', 'steer_max_schedule'): + assert ke[key] == cx5_2022[key], key + + def test_ke_seeds_withheld_on_the_stock_eps(self): + # a stock-EPS KE keeps the flat 800-count scale, so the entry must not apply + cp = brand_cp(brand="mazda", fingerprint=str(CAR.MAZDA_CX5_KE), min_steer_speed=20.0) + assert get_speed_dep_config_for_car(cp) == {} + def test_config_copy_not_cached_dict(self): a = get_speed_dep_config_for_car(cx5_2022_cp()) a['steer_max_schedule'] = 'mutated'