Skip to content
Closed
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
108 changes: 106 additions & 2 deletions opendbc/car/mazda/carcontroller.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from opendbc.car.mazda.longitudinal import BREAKAWAY_FRAMES, AdvertisedLead, StandstillHold
from opendbc.car.mazda.radar_session import RadarSessionManager, RadarSessionState
from opendbc.car.mazda.values import CarControllerParams, Buttons, MazdaFlags
from opendbc.sunnypilot.car.mazda.values import MazdaFlagsSP

from opendbc.sunnypilot.car.mazda.icbm import IntelligentCruiseButtonManagementInterface
from opendbc.sunnypilot.car.stock_ecu import StockEcuState
Expand All @@ -19,6 +20,16 @@

# Send synthetic radar frames to both consumers; panda does not forward locally generated frames.
LONG_BUSES = (0, 2)
TJA_MRCC_MAX_TX_FRAMES = 3
TJA_MRCC_RAW_OFF_CONFIRM_FRAMES = 5
# The body ECU drops discrete presses faster than one per 200 ms (measured, docs/zoompilot/icbm.md),
# and the undo must also stand down before its next slot: the arm clears in PEDALS ~80 ms after a
# press plus the 50 ms confirm, so 200 ms clears both constraints with margin.
TJA_MRCC_TX_PERIOD = 0.2 # s between undo presses
# The press-induced arm appears ~80 ms after the press edge (docs/zoompilot/mazda-lateral.md);
# 1 s is 12x that. PEDALS cannot witness an arm under braking, so the wait counts brake-free
# cycles only.
TJA_MRCC_ARM_WAIT_FRAMES = int(1.0 / DT_CTRL)


class CarController(CarControllerBase, IntelligentCruiseButtonManagementInterface):
Expand Down Expand Up @@ -52,6 +63,13 @@ def __init__(self, dbc_names, CP, CP_SP):
self.tja_press_count = 0
self.tja_press_frame: int | None = None
self.tja_episode_alerted = False
self.tja_button_prev = False
self.mrcc_armed_prev: bool | None = None
self.mrcc_undo_pending = False
self.mrcc_undo_saw_armed = False
self.mrcc_undo_frames = 0
self.mrcc_raw_off_frames = 0
self.mrcc_arm_wait_frames = 0

def update(self, CC, CC_SP, CS, now_nanos):
can_sends = []
Expand Down Expand Up @@ -120,6 +138,9 @@ def update(self, CC, CC_SP, CS, now_nanos):
if self.resume_requested(CC) and self.frame % 5 == 0:
can_sends.append(mazdacan.create_button_cmd(self.packer, self.CP, CS.crz_btns_counter, Buttons.RESUME))

if self.CP_SP.flags & MazdaFlagsSP.TJA_BUTTON:
can_sends.extend(self.update_mrcc_cleanup(CC, CC_SP, CS))

self.apply_torque_last = apply_torque

if self.CP.openpilotLongitudinalControl:
Expand All @@ -139,8 +160,10 @@ def update(self, CC, CC_SP, CS, now_nanos):
can_sends.append(mazdacan.create_steering_control(self.packer, self.CP,
self.frame, apply_torque, CS.cam_lkas))

# Suppress ICBM while cancel or resume is active to avoid competing button frames.
icbm_suppress = CC.cruiseControl.cancel or CC.cruiseControl.resume or CS.cancel_button == 1
# Suppress ICBM while cancel/resume is active or the MRCC cleanup owns CRZ_BTNS; the
# whole TJA hold counts, since the wheel's press pattern owns the counter stream until release.
icbm_suppress = (CC.cruiseControl.cancel or CC.cruiseControl.resume or CS.cancel_button == 1 or
self.mrcc_undo_pending or CS.tja_button == 1)
if not icbm_suppress:
can_sends.extend(IntelligentCruiseButtonManagementInterface.update(self, CC_SP, CS, self.packer, self.frame, self.last_button_frame))

Expand Down Expand Up @@ -194,6 +217,87 @@ def update_camera_tja(self, CC, CS):
self.tja_episode_alerted = True
return can_sends

def update_mrcc_cleanup(self, CC, CC_SP, CS):
# Undo the MRCC arm a physical TJA press causes: the wheel's press reaches every bus-0
# ECU directly, so the button the driver declared as the lateral switch also arms cruise.
# Answer with the driver's own MRCC master press until raw PEDALS confirms the arm is gone.
can_sends = []

raw_armed = bool(CS.mrcc_armed_raw)
# PEDALS drops both cruise bits through a brake transition; the filtered state bridges
# those samples, and only sustained raw-off is authoritative.
self.mrcc_raw_off_frames = 0 if raw_armed else self.mrcc_raw_off_frames + 1
raw_off_confirmed = self.mrcc_raw_off_frames >= TJA_MRCC_RAW_OFF_CONFIRM_FRAMES
# The no-arm wait counts brake-free cycles only: PEDALS holds both cruise bits low under
# braking, so the clock restarts there and runs again once the pedal is off.
self.mrcc_arm_wait_frames = 0 if CS.out.brakePressed else self.mrcc_arm_wait_frames + 1
if self.CP.openpilotLongitudinalControl:
filtered_armed = CS.cruise_available
else:
filtered_armed = CS.out.cruiseState.available
mrcc_armed = raw_armed or (filtered_armed and not raw_off_confirmed)

if CS.tja_button and not self.tja_button_prev:
# PEDALS can already show the press-induced arm in the same cycle as the edge; the
# previous stable sample is the state that existed before the press.
armed_before_press = self.mrcc_armed_prev if self.mrcc_armed_prev is not None else mrcc_armed
self.mrcc_undo_pending = not armed_before_press
self.mrcc_undo_saw_armed = False
self.mrcc_arm_wait_frames = 0
self.tja_button_prev = bool(CS.tja_button)
self.mrcc_armed_prev = mrcc_armed

# The arm is fully reconciled; the budget returns for the next press.
if not mrcc_armed and not CS.cruise_enabled and not CS.out.cruiseState.enabled:
self.mrcc_undo_frames = 0

if not self.mrcc_undo_pending:
return can_sends

self.mrcc_undo_saw_armed |= raw_armed

# The driver's own cruise presses own CRZ_BTNS from this cycle on, and restoring
# stock ECU ownership changes who owns the arm: either way, stand down.
driver_activity = (CS.cancel_button or CS.resume_button or CS.accel_button or CS.decel_button or
CS.mrcc_button or CS.distance_button)
if driver_activity or CS.radar_handback_active or CC_SP.stockEcuHandBack:
self.mrcc_undo_pending = False
return can_sends

# The arm never appeared: a takeover already disarmed it, or this car does not arm
# through the press. Past the bounded brake-free wait, stand down: ICBM gets the button
# stream back instead of staying suppressed until the next cruise press.
if not self.mrcc_undo_saw_armed:
if self.mrcc_arm_wait_frames >= TJA_MRCC_ARM_WAIT_FRAMES:
self.mrcc_undo_pending = False
return can_sends

if raw_off_confirmed:
self.mrcc_undo_pending = False
return can_sends

# openpilot's own cancel or resume interleaves button frames; wait it out rather
# than race the counter stream.
if CC.cruiseControl.cancel or CC.cruiseControl.resume:
return can_sends

# Never inside the driver's held press.
if CS.tja_button:
return can_sends

# One press per body-paced slot, within the episode budget; never while PEDALS reads
# disarmed, where the master press would arm instead of disarm. last_button_frame also
# paces ICBM, so nothing else transmits over the same slots.
if raw_armed and self.mrcc_undo_frames < TJA_MRCC_MAX_TX_FRAMES and \
(self.frame - self.last_button_frame) * DT_CTRL > TJA_MRCC_TX_PERIOD:
can_sends.append(mazdacan.create_mrcc_off_cmd(self.packer, CS.crz_btns_counter))
self.last_button_frame = self.frame
self.mrcc_undo_frames += 1
if self.mrcc_undo_frames >= TJA_MRCC_MAX_TX_FRAMES:
self.mrcc_undo_pending = False

return can_sends

def resume_requested(self, CC) -> bool:
"""The resume button belongs to the stock-longitudinal path alone. Under openpilot longitudinal
the hold is released in-protocol (stop bits drop, RESUME_UNLATCHING pulses, the command ramps),
Expand Down
23 changes: 21 additions & 2 deletions opendbc/car/mazda/carstate.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,13 @@ def __init__(self, CP, CP_SP):
self.resume_button = 0
self.main_button = 0
self.tja_button = 0
self.mrcc_button = 0
self.crz_btns_seen = False

self.cruise_available = False
self.cruise_enabled = False
# Unfiltered PEDALS cruise state; the filtered public state bridges brake dropouts.
self.mrcc_armed_raw = False
self.cruise_enabled_blocked = True
self.brake_pressed_prev = False
self.stock_radar_silent_frames = 0
Expand Down Expand Up @@ -225,11 +229,14 @@ def update(self, can_parsers) -> tuple[structs.CarState, structs.CarStateSP]:
ret.stockFcw = (self.cam_empty_seen and cam_empty["STATUS"] != 0x7F) or \
ped["PED_WARNING"] == 1 or ped["BRAKE_WARNING"] == 1

acc_armed = cp.vl["PEDALS"]["ACC_OFF"] == 1
acc_active = cp.vl["PEDALS"]["ACC_ACTIVE"] == 1
# Unfiltered, both longitudinal modes: the TJA-press cleanup reads it.
self.mrcc_armed_raw = acc_armed or acc_active

if self.CP.openpilotLongitudinalControl:
# After radar teardown, derive cruise state from PEDALS. Hold the previous state through
# brake-only samples where both cruise bits are transiently low.
acc_armed = cp.vl["PEDALS"]["ACC_OFF"] == 1
acc_active = cp.vl["PEDALS"]["ACC_ACTIVE"] == 1
brake_free = not ret.brakePressed and not self.brake_pressed_prev
# Retain wheel-cancel context until PEDALS reflects the main-state change.
if cp.vl["CRZ_BTNS"]["CAN_OFF"] == 1:
Expand Down Expand Up @@ -351,6 +358,7 @@ def update(self, can_parsers) -> tuple[structs.CarState, structs.CarStateSP]:
prev_cancel_button = self.cancel_button
prev_resume_button = self.resume_button
prev_main_button = self.main_button
prev_mrcc_button = self.mrcc_button
prev_tja_button = self.tja_button
self.distance_button = cp.vl["CRZ_BTNS"]["DISTANCE_LESS"]
# SET_P is the wheel's increase button; RES is a distinct resume button.
Expand All @@ -360,6 +368,15 @@ def update(self, can_parsers) -> tuple[structs.CarState, structs.CarStateSP]:
self.cancel_button = cp.vl["CRZ_BTNS"]["CAN_OFF"]
self.resume_button = cp.vl["CRZ_BTNS"]["RES"]
self.main_button = int(cp.vl["CRZ_BTNS"]["MODE_X"] == 1 and cp.vl["CRZ_BTNS"]["MODE_Y"] == 1)
# BIT1 is active-low: a 0 on the bus-0 parser is the wheel's MRCC master press. Gated on
# the declaration because an undeclared wheel's BIT1 idle level is unknown, and held
# unpressed until the wheel's first frame: the parser reads zeros before it, which the
# active-low decode would turn into a phantom press at startup.
if self.CP_SP.flags & MazdaFlagsSP.TJA_BUTTON:
self.crz_btns_seen = self.crz_btns_seen or len(cp.vl_all["CRZ_BTNS"]["BIT1"]) > 0
self.mrcc_button = int(cp.vl["CRZ_BTNS"]["BIT1"] == 0) if self.crz_btns_seen else 0
else:
self.mrcc_button = 0
# Only a car declared to have the physical TJA button reports it as the MADS switch.
self.tja_button = int(cp.vl["CRZ_BTNS"]["TJA_BUTTON"] == 1) if self.CP_SP.flags & MazdaFlagsSP.TJA_BUTTON else 0

Expand All @@ -370,6 +387,8 @@ def update(self, can_parsers) -> tuple[structs.CarState, structs.CarStateSP]:
*create_button_events(self.cancel_button, prev_cancel_button, {1: ButtonType.cancel}),
*create_button_events(self.resume_button, prev_resume_button, {1: ButtonType.resumeCruise}),
*create_button_events(self.main_button, prev_main_button, {1: ButtonType.mainCruise}),
# A held press of this button must freeze ICBM through the cruise button timers in the openpilot tree.
*create_button_events(self.mrcc_button, prev_mrcc_button, {1: ButtonType.mainCruise}),
*create_button_events(self.tja_button, prev_tja_button, {1: ButtonType.lkas}),
]

Expand Down
13 changes: 13 additions & 0 deletions opendbc/car/mazda/mazdacan.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,3 +230,16 @@ def create_button_cmd(packer, CP, counter, button, bus=0):
}

return packer.make_can_msg("CRZ_BTNS", bus, values)


def create_mrcc_off_cmd(packer, counter):
# The wheel's MRCC master press, active-low: every button bit 0 with its inversion 1,
# the master signature in BIT1/BIT1_INV plus BIT2/BIT3, counter plus one. Only the
# TJA-press cleanup sends it, and the panda pins the exact bytes in mazda_mrcc_off_msg_valid.
values = {
"CAN_OFF_INV": 1, "SET_P_INV": 1, "RES_INV": 1, "SET_M_INV": 1,
"DISTANCE_LESS_INV": 1, "DISTANCE_MORE_INV": 1, "MODE_X_INV": 1, "MODE_Y_INV": 1,
"BIT1_INV": 1, "BIT2": 1, "BIT3": 1,
"CTR": (counter + 1) % 16,
}
return packer.make_can_msg("CRZ_BTNS", 0, values)
16 changes: 13 additions & 3 deletions opendbc/car/mazda/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,18 +139,24 @@ def set_car_state(cs: CarState, out=None, *, brake_hold=False, stock_radar_alive
radar_bus_healthy=True, steer_undelivered=False,
lkas_blocked=False, lkas_effective=0, steer_first_engage_hold=False, lkas_allowed_speed=True, lkas_rejected=0,
lkas_fault=False, crz_btns_counter=0, stock_tja=0,
cancel_button=0, accel_button=0, decel_button=0, **out_kwargs) -> CarState:
cancel_button=0, accel_button=0, decel_button=0,
tja_button=0, mrcc_button=0,
mrcc_armed_raw=False, cruise_available=None, cruise_enabled=None,
radar_handback_active=False,
**out_kwargs) -> CarState:
"""Put the controller-facing state of a real CarState where a test wants it.

Every keyword is reset to its default on each call, so a test that drives frame by frame
gets the same semantics as a fresh state each frame. The stock-radar and FSC properties are
derived from their frame counters, so those are what get seeded: a silent radar reads as
gone (guard-long silence) unless a test is about the gap between the two windows.
The filtered cruise fields mirror cruiseState unless a test overrides them explicitly,
which is how a brake-dropout (raw low, filtered held) is staged.
"""
cs.out = out if out is not None else car_state(**out_kwargs)
cs.brake_hold = brake_hold
cs.cruise_enabled = cs.out.cruiseState.enabled
cs.cruise_available = cs.out.cruiseState.available
cs.cruise_enabled = cs.out.cruiseState.enabled if cruise_enabled is None else cruise_enabled
cs.cruise_available = cs.out.cruiseState.available if cruise_available is None else cruise_available
cs.stock_radar_seen = True
cs.radar_bus_healthy = radar_bus_healthy
cs.radar_session_response = radar_session_response
Expand All @@ -177,6 +183,10 @@ def set_car_state(cs: CarState, out=None, *, brake_hold=False, stock_radar_alive
cs.cancel_button = cancel_button
cs.accel_button = accel_button
cs.decel_button = decel_button
cs.tja_button = tja_button
cs.mrcc_button = mrcc_button
cs.mrcc_armed_raw = mrcc_armed_raw
cs.radar_handback_active = radar_handback_active
return cs


Expand Down
33 changes: 29 additions & 4 deletions opendbc/car/mazda/tests/test_mazda_carstate.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from opendbc.car import structs
from opendbc.car.common.conversions import Conversions as CV
from opendbc.car.mazda import mazdacan
from opendbc.car.mazda.carstate import CAM_LANEINFO_FRESH_FRAMES, STOCK_CTS_ALERT_FRAMES
from opendbc.car.mazda.carstate import CAM_LANEINFO_FRESH_FRAMES, STOCK_CTS_ALERT_FRAMES, ButtonType
from opendbc.car.mazda.tests.conftest import car_interface, packer
from opendbc.car.mazda.values import CarControllerParams
from opendbc.sunnypilot.car.mazda.values import MazdaFlagsSP
Expand Down Expand Up @@ -640,15 +640,15 @@ def _declared(self):

def test_tja_press_emits_an_lkas_event(self):
CI, pk = self._declared(), packer()
self._btns(CI, pk, 0, TJA_BUTTON=0)
ret = self._btns(CI, pk, 1, TJA_BUTTON=1)
self._btns(CI, pk, 0, TJA_BUTTON=0, BIT1=1)
ret = self._btns(CI, pk, 1, TJA_BUTTON=1, BIT1=1)
assert [be.type for be in ret.buttonEvents] == [self.ButtonType.lkas]
assert ret.buttonEvents[0].pressed

def test_no_event_without_the_button(self):
CI, pk = self._declared(), packer()
for i in range(10):
ret = self._btns(CI, pk, i, TJA_BUTTON=0)
ret = self._btns(CI, pk, i, TJA_BUTTON=0, BIT1=1)
assert not [be for be in ret.buttonEvents if be.type == self.ButtonType.lkas]

def test_undeclared_ignores_the_bit(self):
Expand Down Expand Up @@ -734,3 +734,28 @@ def test_rolling_or_leaving_standby_releases(self, release):
kw.update(release)
rig.step(100, 0, kw.pop('blocked'), **kw)
assert not rig.CS.steer_first_engage_hold


class TestMrccButtonEvent:
"""The wheel's MRCC master press (CRZ_BTNS BIT1, active low) as a buttonEvent.

Declared cars publish mainCruise like any master button; undeclared cars never read the
bit, so an unknown idle level there cannot produce events.
"""

def test_press_publishes_maincruise_on_a_declared_car(self):
CI = car_interface()
CI.CP_SP.flags |= MazdaFlagsSP.TJA_BUTTON
pk = packer()
ret, _ = feed(CI, 0, pk.make_can_msg("CRZ_BTNS", 0, {"BIT1": 1}))
assert not ret.buttonEvents
ret, _ = feed(CI, 1, pk.make_can_msg("CRZ_BTNS", 0, {"BIT1": 0, "BIT1_INV": 1}))
assert [(be.type, be.pressed) for be in ret.buttonEvents] == [(ButtonType.mainCruise, True)]

def test_press_is_invisible_on_an_undeclared_car(self):
CI = car_interface()
pk = packer()
ret, _ = feed(CI, 0, pk.make_can_msg("CRZ_BTNS", 0, {"BIT1": 1}))
ret, _ = feed(CI, 1, pk.make_can_msg("CRZ_BTNS", 0, {"BIT1": 0, "BIT1_INV": 1}))
assert not ret.buttonEvents
assert CI.CS.mrcc_button == 0
Loading
Loading