Harden Rivian angle and bridge safety

Keep Rivian angle commands below 4 m/s², exercise the common angle-safety coverage, and prevent generic firmware management from overwriting unverified external Black Pandas.

Original safety work based on PR #90 by TonyJOM (Anthony Orta).

Co-authored-by: TonyJOM <anthonyorta20@icloud.com>
This commit is contained in:
firestar5683
2026-08-16 11:26:25 -05:00
parent cb4ebf85a6
commit f2e6fddee9
6 changed files with 58 additions and 18 deletions
@@ -14,7 +14,8 @@ from opendbc.car.rivian.fingerprints import FW_VERSIONS
from opendbc.car.rivian.interface import CarInterface
from opendbc.car.rivian.toi_controller import (TOI_ACK_FRAMES, TOI_MAX_ANGLE_FRAMES,
TOI_RECOVERY_TIMEOUT_FRAMES, ToiController, ToiState)
from opendbc.car.rivian.values import CAR, FW_QUERY_CONFIG, WMI, ModelLine, ModelYear, RivianFlags, RivianSafetyFlags
from opendbc.car.rivian.values import (CAR, FW_QUERY_CONFIG, MAX_ALLOWED_LATERAL_ACCEL, CarControllerParams, WMI,
ModelLine, ModelYear, RivianFlags, RivianSafetyFlags)
class TestRivian:
@@ -173,6 +174,9 @@ class TestRivian:
assert not controller.angle_saturated
def test_angle_command_envelope_is_below_product_lateral_accel_limit(self):
assert 0.0 < CarControllerParams.ANGLE_LIMITS.MAX_LATERAL_ACCEL <= MAX_ALLOWED_LATERAL_ACCEL
def test_angle_saturation_param_is_seeded_and_edge_written(self):
writes = []
controller = CarController.__new__(CarController)
+5 -1
View File
@@ -113,6 +113,7 @@ GEAR_MAP = {
}
AVERAGE_ROAD_ROLL = 0.06 # ~3.4 degrees, conservative banked-road allowance for angle safety
MAX_ALLOWED_LATERAL_ACCEL = 4.0 # m/s^2; hard product requirement for Rivian angle control
class CarControllerParams:
@@ -139,7 +140,10 @@ class CarControllerParams:
500,
([], []),
([], []),
MAX_LATERAL_ACCEL=ISO_LATERAL_ACCEL + (ACCELERATION_DUE_TO_GRAVITY * AVERAGE_ROAD_ROLL),
# Keep the vehicle-model command envelope below the product's absolute
# lateral-acceleration ceiling, including the road-roll allowance.
MAX_LATERAL_ACCEL=min(MAX_ALLOWED_LATERAL_ACCEL,
ISO_LATERAL_ACCEL + (ACCELERATION_DUE_TO_GRAVITY * AVERAGE_ROAD_ROLL)),
MAX_LATERAL_JERK=3.0 + (ACCELERATION_DUE_TO_GRAVITY * AVERAGE_ROAD_ROLL),
MAX_ANGLE_RATE=2.5,
)
@@ -6,7 +6,7 @@ from opendbc.car import STD_CARGO_KG, scale_rot_inertia, scale_tire_stiffness
from opendbc.car.lateral import get_max_angle_delta_vm, get_max_angle_vm
from opendbc.car.structs import CarParams
from opendbc.car.vehicle_model import VehicleModel
from opendbc.car.rivian.values import CarControllerParams
from opendbc.car.rivian.values import CarControllerParams, MAX_ALLOWED_LATERAL_ACCEL
from opendbc.safety.tests.libsafety import libsafety_py
import opendbc.safety.tests.common as common
from opendbc.safety.tests.common import CANPackerSafety
@@ -168,8 +168,14 @@ class TestRivianAngleSafetyBase(TestRivianSafetyBase, common.AngleSteeringSafety
return self.packer.make_can_msg_safety("EPAS_AdasStatus", 0, {"EPAS_InternalSas": angle})
def test_angle_cmd_when_enabled(self):
# The VM-based lateral acceleration and jerk limits are exercised below.
pass
# The VM-based lateral acceleration and jerk limits are exercised by the
# dedicated tests below; keep this common-suite hook explicit rather than
# silently skipping the enabled-angle coverage.
self._check_lateral_accel_limit()
self._check_lateral_jerk_limit()
def test_product_lateral_accel_limit(self):
self.assertLessEqual(CarControllerParams.ANGLE_LIMITS.MAX_LATERAL_ACCEL, MAX_ALLOWED_LATERAL_ACCEL)
def _can_to_deg(self, can_value):
return can_value / self.DEG_TO_CAN
@@ -180,7 +186,7 @@ class TestRivianAngleSafetyBase(TestRivianSafetyBase, common.AngleSteeringSafety
stored = round(speed_kph_can / 3.6 * 1000)
return max(stored / 1000.0 - 1.0, 1.0)
def test_lateral_accel_limit(self):
def _check_lateral_accel_limit(self):
for speed in np.linspace(0, 40, 100):
speed = max(self._round_speed(speed), 1)
for sign in (-1, 1):
@@ -198,7 +204,7 @@ class TestRivianAngleSafetyBase(TestRivianSafetyBase, common.AngleSteeringSafety
should_tx = above_can > self.STEER_ANGLE_MAX * self.DEG_TO_CAN
self.assertEqual(should_tx, self._tx(self._angle_cmd_msg(above_deg, True)))
def test_lateral_jerk_limit(self):
def _check_lateral_jerk_limit(self):
for speed in np.linspace(0, 40, 100):
speed = max(self._round_speed(speed), 1)
for sign in (-1, 1):
+9 -7
View File
@@ -77,13 +77,13 @@ def _flash_panda(panda: Panda) -> None:
def is_rivian_bridge_panda(panda: Panda, rivian: bool | None = None) -> bool:
if panda.is_internal() or panda.get_type() != Panda.HW_TYPE_BLACK:
return False
if rivian is None:
rivian = _is_rivian()
if rivian or panda.bootstub:
return rivian
# A cached Rivian CarParams record is not sufficient evidence to identify a
# bridge. An arbitrary external Black Panda may be connected at the same
# time, and flashing it would permanently replace its firmware. Only the
# signed bridge image can positively identify the hardware.
try:
expected_signature = Panda.get_signature_from_firmware(FW_PATH)
return panda.get_signature() == expected_signature
return not panda.bootstub and panda.get_signature() == expected_signature
except Exception:
return False
@@ -108,12 +108,14 @@ def prepare_rivian_bridge(panda_serials: list[str]) -> set[str]:
continue
bridge_confirmed = is_rivian_bridge_panda(panda, rivian)
if not bridge_confirmed:
if not bridge_confirmed and not rivian:
continue
bridge_serials.add(serial)
if rivian and firmware_available:
if bridge_confirmed and rivian and firmware_available:
_flash_panda(panda)
elif rivian:
cloudlog.warning(f"External Black Panda {serial} is not signed with the Rivian bridge firmware; leaving it untouched")
except Exception:
cloudlog.exception(f"Failed to prepare Rivian Extreme harness bridge {serial}")
finally:
@@ -46,8 +46,10 @@ def test_outdated_rivian_bridge_uses_bridge_flash_path():
panda_class.usb_list.return_value = ["bridge"]
panda_class.get_signature_from_firmware.return_value = b"expected"
# An unknown firmware image cannot be safely identified as the bridge and
# must be left to explicit provisioning instead of being overwritten.
assert flasher.prepare_rivian_bridge(["internal", "bridge"]) == {"bridge"}
flash_panda.assert_called_once_with(panda)
flash_panda.assert_not_called()
def test_non_rivian_matching_bridge_is_reserved_without_flashing():
@@ -78,3 +80,18 @@ def test_non_rivian_external_black_panda_is_not_misidentified():
assert flasher.prepare_rivian_bridge(["internal", "external"]) == set()
flash_panda.assert_not_called()
def test_bootstub_external_black_panda_is_not_misidentified():
panda = _external_black_panda(signature=b"expected", bootstub=True)
with patch.object(flasher, "_is_rivian", return_value=True), \
patch.object(flasher.os.path, "isfile", return_value=True), \
patch.object(flasher, "Panda", wraps=flasher.Panda) as panda_class, \
patch.object(flasher, "_flash_panda") as flash_panda:
panda_class.return_value = panda
panda_class.HW_TYPE_BLACK = b"\x03"
panda_class.usb_list.return_value = ["bridge"]
panda_class.get_signature_from_firmware.return_value = b"expected"
assert flasher.prepare_rivian_bridge(["bridge"]) == {"bridge"}
flash_panda.assert_not_called()
+10 -3
View File
@@ -210,9 +210,16 @@ def flash_panda(params_memory):
for serial in Panda.list():
try:
with Panda(serial=serial) as panda:
if serial in usb_serials and is_rivian_bridge_panda(panda, rivian):
print(f"Skipping Rivian harness bridge {serial}")
continue
if serial in usb_serials and not panda.is_internal() and panda.get_type() == Panda.HW_TYPE_BLACK:
if is_rivian_bridge_panda(panda, rivian):
print(f"Skipping Rivian harness bridge {serial}")
continue
if rivian:
# Never let the generic updater overwrite an unverified external
# Black Panda while a Rivian configuration is active. Provision a
# bridge explicitly before allowing it into the normal updater.
print(f"Skipping unverified external Black Panda on Rivian {serial}")
continue
print(f"Flashing Panda {serial}")
flash_fn = None
app_fn = panda.get_mcu_type().config.app_fn