Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion opendbc/car/mazda/carcontroller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
17 changes: 17 additions & 0 deletions opendbc/car/mazda/fingerprints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
34 changes: 29 additions & 5 deletions opendbc/car/mazda/interface.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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]

Expand All @@ -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
8 changes: 7 additions & 1 deletion opendbc/car/mazda/mazdacan.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
114 changes: 108 additions & 6 deletions opendbc/car/mazda/tests/test_mazda_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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),
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions opendbc/car/mazda/tests/test_mazda_mazdacan.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading