Add the Volvo C1 port with matching Python registration and exact native parameter admission.

Co-authored-by: firestarsdog <229254897+firestarsdog@users.noreply.github.com>
This commit is contained in:
firestar5683
2026-09-25 14:53:18 -05:00
parent 2b3fbbf6f7
commit 710f78bc48
20 changed files with 1317 additions and 29 deletions
+20
View File
@@ -14,14 +14,34 @@ from opendbc.car.tesla.values import CAR as TESLA
from opendbc.car.toyota.values import CAR as TOYOTA
from opendbc.car.values import Platform
from opendbc.car.volkswagen.values import CAR as VOLKSWAGEN
from opendbc.car.volvo.values import CAR as VOLVO
from opendbc.car.body.values import CAR as COMMA
from opendbc.car.byd.values import CAR as BYD
from opendbc.car.psa.values import CAR as PSA
from opendbc.car.mg.values import CAR as MG
# Configurations with source tests but no matching recorded route.
unrecorded_route_gaps = {
GM.CHEVROLET_VOLT_CC: "gm/tests/test_volt_cc_gas.py",
GM.CHEVROLET_MALIBU_HYBRID_CC: "gm/tests/test_ordinary_cc.py",
TOYOTA.TOYOTA_PRIUS_RETROFIT: "toyota/tests/test_retrofit.py",
TESLA.TESLA_MODEL_S_HW1: "tesla/tests/test_hw1.py",
}
# FIXME: add routes for these cars
non_tested_cars = [
MOCK.MOCK,
GM.CHEVROLET_SUBURBAN,
HYUNDAI.HYUNDAI_PALISADE_2023,
HYUNDAI.HYUNDAI_IONIQ_5_PE,
HYUNDAI.KIA_EV6_2025,
HYUNDAI.KIA_EV9,
HYUNDAI.KIA_RAY_EV,
TESLA.TESLA_MODEL_S_PREAP,
TOYOTA.TOYOTA_MATRIX_RETROFIT,
VOLVO.VOLVO_V40,
# These additional source-tested configurations still lack recorded routes.
*unrecorded_route_gaps,
GM.CADILLAC_ATS,
GM.HOLDEN_ASTRA,
GM.CHEVROLET_MALIBU,
+16 -1
View File
@@ -1,7 +1,11 @@
import unittest
from pathlib import Path
from opendbc.car.values import PLATFORMS
from opendbc.car.tests.routes import non_tested_cars, routes
from opendbc.car.tests.routes import non_tested_cars, routes, unrecorded_route_gaps
from opendbc.car.gm.values import CAR as GM
from opendbc.car.tesla.values import CAR as TESLA
from opendbc.car.toyota.values import CAR as TOYOTA
class TestRoutes(unittest.TestCase):
@@ -11,3 +15,14 @@ class TestRoutes(unittest.TestCase):
with self.subTest(platform=platform):
assert platform in set(tested_platforms) | set(non_tested_cars), \
f"Missing test route for {platform}. Add a route to opendbc/car/tests/routes.py"
def test_explicit_unrecorded_configuration_gaps(self):
self.assertEqual(set(unrecorded_route_gaps), {
GM.CHEVROLET_VOLT_CC, GM.CHEVROLET_MALIBU_HYBRID_CC, TOYOTA.TOYOTA_PRIUS_RETROFIT, TESLA.TESLA_MODEL_S_HW1,
})
self.assertFalse(set(unrecorded_route_gaps) & {route.car_model for route in routes})
self.assertTrue(set(unrecorded_route_gaps) <= set(non_tested_cars))
car_root = Path(__file__).resolve().parents[1]
for platform, source_test in unrecorded_route_gaps.items():
with self.subTest(platform=platform):
self.assertTrue((car_root / source_test).is_file())
+2 -1
View File
@@ -15,9 +15,10 @@ from opendbc.car.mg.values import CAR as MG
from opendbc.car.subaru.values import CAR as SUBARU
from opendbc.car.tesla.values import CAR as TESLA
from opendbc.car.toyota.values import CAR as TOYOTA
from opendbc.car.volvo.values import CAR as VOLVO
from opendbc.car.volkswagen.values import CAR as VOLKSWAGEN
Platform = BODY | BYD | CHRYSLER | FORD | GM | HONDA | HYUNDAI | MAZDA | MOCK | MG | NISSAN | PSA | RIVIAN | SUBARU | TESLA | TOYOTA | VOLKSWAGEN
Platform = BODY | BYD | CHRYSLER | FORD | GM | HONDA | HYUNDAI | MAZDA | MOCK | MG | NISSAN | PSA | RIVIAN | SUBARU | TESLA | TOYOTA | VOLVO | VOLKSWAGEN
BRANDS = get_args(Platform)
PLATFORMS: dict[str, Platform] = {str(platform): platform for brand in BRANDS for platform in brand}
@@ -0,0 +1,69 @@
from collections import deque
import numpy as np
from opendbc.can import CANPacker
from opendbc.car import Bus
from opendbc.car.interfaces import CarControllerBase
from opendbc.car.lateral import apply_std_steer_angle_limits
from opendbc.car.volvo.volvocan import create_c1_cancel, create_c1_pscm_message, create_c1_steering_control
from opendbc.car.volvo.values import CarControllerParams
class CarController(CarControllerBase):
def __init__(self, dbc_names, CP):
super().__init__(dbc_names, CP)
self.packer = CANPacker(dbc_names[Bus.pt])
self.apply_angle_last = 0.0
self.c1_torque_samples = deque(maxlen=CarControllerParams.C1_N_ZERO_TORQUE)
self.c1_recovery_until = -1
def update(self, CC, CS, now_nanos):
can_sends = []
actuators = CC.actuators
if self.frame % 2 == 0: # stock FSM1 and PSCM1 messages are 50 Hz
requested_active = CC.latActive and CS.out.vEgo > self.CP.minSteerSpeed
recovering = requested_active and self.frame < self.c1_recovery_until
if not requested_active:
self.c1_torque_samples.clear()
self.c1_recovery_until = -1
elif recovering:
self.c1_torque_samples.clear()
else:
if self.c1_recovery_until >= 0:
self.c1_recovery_until = -1
self.c1_torque_samples.clear()
self.c1_torque_samples.append(CS.c1_lka_torque)
if len(self.c1_torque_samples) == CarControllerParams.C1_N_ZERO_TORQUE and all(torque == 0 for torque in self.c1_torque_samples):
self.c1_recovery_until = self.frame + 100
self.c1_torque_samples.clear()
recovering = True
lat_active = requested_active and not recovering
desired_angle = float(
np.clip(
actuators.steeringAngleDeg,
CS.out.steeringAngleDeg - CarControllerParams.C1_ANGLE_ERROR,
CS.out.steeringAngleDeg + CarControllerParams.C1_ANGLE_ERROR,
)
)
apply_angle = apply_std_steer_angle_limits(
desired_angle,
self.apply_angle_last,
CS.out.vEgoRaw,
CS.out.steeringAngleDeg,
lat_active,
CarControllerParams.C1_ANGLE_LIMITS,
)
can_sends.append(create_c1_pscm_message(self.packer, CS.c1_msg_pscm))
can_sends.append(create_c1_steering_control(self.packer, apply_angle, lat_active))
self.apply_angle_last = apply_angle
if CC.cruiseControl.cancel and self.frame % 10 == 0:
can_sends.append(create_c1_cancel(self.packer))
new_actuators = actuators.as_builder()
new_actuators.steeringAngleDeg = self.apply_angle_last
self.frame += 1
return new_actuators, can_sends
@@ -0,0 +1,98 @@
from opendbc.can import CANParser
from opendbc.car import Bus, ButtonType, create_button_events, structs
from opendbc.car.common.conversions import Conversions as CV
from opendbc.car.interfaces import CarStateBase
from opendbc.car.volvo.values import DBC
GearShifter = structs.CarState.GearShifter
class CarState(CarStateBase):
def __init__(self, CP):
super().__init__(CP)
self.c1_msg_pscm = {}
self.c1_lka_torque = 0
self.c1_button_states = dict.fromkeys(
("ACCOnOffBtn", "ACCStopBtn", "ACCSetBtn", "ACCResumeBtn", "ACCMinusBtn", "TimeGapIncreaseBtn", "TimeGapDecreaseBtn"), False
)
def update(self, can_parsers):
cp = can_parsers[Bus.pt]
cp_cam = can_parsers[Bus.cam]
ret = structs.CarState()
ret.vEgoRaw = cp.vl["VehicleSpeed1"]["VehicleSpeed"] * CV.KPH_TO_MS
ret.vEgo, ret.aEgo = self.update_speed_kf(ret.vEgoRaw)
ret.standstill = ret.vEgoRaw < 0.1
ret.steeringAngleDeg = cp.vl["PSCM1"]["SteeringAngleServo"]
ret.steeringTorque = cp.vl["PSCM1"]["LKATorque"]
ret.steeringPressed = False
ret.gasPressed = cp.vl["PedalandBrake"]["AccPedal"] > 5.0
ret.brakePressed = bool(cp.vl["PedalandBrake"]["BrakePedalActive2"] or cp.vl["PedalandBrake"]["BrakePedalActive"])
ret.gearShifter = {
0: GearShifter.park,
1: GearShifter.reverse,
2: GearShifter.neutral,
3: GearShifter.drive,
}.get(int(cp.vl["TCM0"]["GearShifter"]), GearShifter.unknown)
ret.cruiseState.available = bool(cp_cam.vl["FSM0"]["ACCStatusOnOff"])
ret.cruiseState.enabled = bool(cp_cam.vl["FSM0"]["ACCStatusActive"])
ret.cruiseState.speed = cp.vl["ACC"]["SpeedTargetACC"] * CV.KPH_TO_MS
ret.cruiseState.nonAdaptive = False
ret.cruiseState.standstill = ret.standstill
turn_signal = int(cp.vl["MiscCarInfo"]["TurnSignal"])
ret.leftBlinker, ret.rightBlinker = self.update_blinker_from_stalk(50, turn_signal == 1, turn_signal == 3)
ret.doorOpen = False
ret.seatbeltUnlatched = False
button_types = {
"ACCOnOffBtn": ButtonType.mainCruise,
"ACCStopBtn": ButtonType.cancel,
"ACCSetBtn": ButtonType.setCruise,
"ACCResumeBtn": ButtonType.resumeCruise,
"ACCMinusBtn": ButtonType.decelCruise,
"TimeGapIncreaseBtn": ButtonType.gapAdjustCruise,
"TimeGapDecreaseBtn": ButtonType.gapAdjustCruise,
}
button_events = []
for signal, button_type in button_types.items():
pressed = bool(cp.vl["CCButtons"][signal])
button_events.extend(create_button_events(pressed, self.c1_button_states[signal], {True: button_type}))
self.c1_button_states[signal] = pressed
ret.buttonEvents = button_events
self.c1_msg_pscm = cp.vl["PSCM1"]
self.c1_lka_torque = int(cp.vl["PSCM1"]["LKATorque"])
return ret
@staticmethod
def get_can_parsers(CP):
return {
Bus.pt: CANParser(
DBC[CP.carFingerprint][Bus.pt],
[
("VehicleSpeed1", 50),
("CCButtons", 100),
("PSCM1", 50),
("PedalandBrake", 100),
("TCM0", 10),
("ACC", 17),
("MiscCarInfo", 25),
],
0,
),
Bus.cam: CANParser(
DBC[CP.carFingerprint][Bus.cam],
[
("FSM0", 100),
("FSM1", 50),
],
2,
),
}
@@ -0,0 +1,188 @@
from opendbc.car.volvo.values import CAR
FINGERPRINTS = {
CAR.VOLVO_V40: [
{
8: 8,
16: 8,
48: 8,
64: 8,
85: 8,
101: 8,
112: 8,
114: 8,
117: 8,
128: 8,
176: 8,
192: 8,
208: 8,
224: 8,
240: 8,
245: 8,
256: 8,
272: 8,
288: 8,
291: 8,
293: 8,
304: 8,
325: 8,
336: 8,
352: 8,
424: 8,
432: 8,
437: 8,
464: 8,
472: 8,
480: 8,
528: 8,
608: 8,
624: 8,
640: 8,
648: 8,
652: 8,
656: 8,
657: 8,
681: 8,
693: 8,
704: 8,
707: 8,
709: 8,
816: 8,
832: 8,
848: 8,
853: 8,
864: 8,
880: 8,
912: 8,
928: 8,
943: 8,
944: 8,
968: 8,
970: 8,
976: 8,
992: 8,
997: 8,
1024: 8,
1029: 8,
1061: 8,
1072: 8,
1409: 8,
},
{
8: 8,
16: 8,
64: 8,
85: 8,
101: 8,
112: 8,
114: 8,
117: 8,
128: 8,
176: 8,
192: 8,
224: 8,
240: 8,
245: 8,
256: 8,
272: 8,
288: 8,
291: 8,
293: 8,
304: 8,
325: 8,
336: 8,
424: 8,
432: 8,
437: 8,
464: 8,
472: 8,
480: 8,
528: 8,
608: 8,
648: 8,
652: 8,
656: 8,
657: 8,
681: 8,
693: 8,
704: 8,
707: 8,
709: 8,
816: 8,
832: 8,
864: 8,
880: 8,
912: 8,
928: 8,
943: 8,
944: 8,
968: 8,
970: 8,
976: 8,
992: 8,
997: 8,
1024: 8,
1029: 8,
1061: 8,
1072: 8,
1409: 8,
},
{
8: 8,
16: 8,
64: 8,
85: 8,
101: 8,
112: 8,
114: 8,
117: 8,
128: 8,
176: 8,
192: 8,
224: 8,
240: 8,
245: 8,
256: 8,
272: 8,
288: 8,
291: 8,
293: 8,
304: 8,
325: 8,
336: 8,
424: 8,
432: 8,
437: 8,
464: 8,
472: 8,
480: 8,
528: 8,
608: 8,
648: 8,
652: 8,
657: 8,
681: 8,
693: 8,
704: 8,
707: 8,
709: 8,
816: 8,
864: 8,
880: 8,
912: 8,
928: 8,
943: 8,
944: 8,
968: 8,
970: 8,
976: 8,
992: 8,
997: 8,
1024: 8,
1029: 8,
1072: 8,
1409: 8,
},
]
}
FW_VERSIONS = {}
@@ -0,0 +1,24 @@
from opendbc.car import structs, get_safety_config
from opendbc.car.interfaces import CarInterfaceBase
from opendbc.car.volvo.carstate import CarState
from opendbc.car.volvo.carcontroller import CarController
from opendbc.car.volvo.values import VolvoSafetyFlags
class CarInterface(CarInterfaceBase):
CarState = CarState
CarController = CarController
@staticmethod
def _get_params(ret, candidate, fingerprint, car_fw, alpha_long, is_release, docs):
ret.brand = 'volvo'
ret.safetyConfigs = [get_safety_config(structs.CarParams.SafetyModel.volvo, VolvoSafetyFlags.C1.value)]
ret.steerActuatorDelay = 0.2
ret.steerLimitTimer = 0.1
ret.steerAtStandstill = False
ret.steerControlType = structs.CarParams.SteerControlType.angle
ret.radarUnavailable = True
ret.alphaLongitudinalAvailable = False
ret.pcmCruise = True
ret.transmissionType = structs.CarParams.TransmissionType.automatic
return ret
@@ -0,0 +1,138 @@
import unittest
import pytest
from opendbc.can import CANPacker, CANParser
from opendbc.car import structs
from opendbc.car.volvo.values import CAR, DBC
from opendbc.car.volvo.interface import CarInterface
from opendbc.car.volvo.carstate import CarState
from opendbc.car.volvo.carcontroller import CarController
def params(alpha=False, release=False):
cp = CarInterface.get_non_essential_params(CAR.VOLVO_V40)
return CarInterface._get_params(cp, CAR.VOLVO_V40, {0: {}, 1: {}, 2: {}}, [], alpha, release, False)
def feed(parsers, time=1000000000, torque=10):
packer = CANPacker('volvo_v40_2017_pt')
messages = [
packer.make_can_msg('VehicleSpeed1', 0, {'VehicleSpeed': 72}),
packer.make_can_msg('CCButtons', 0, {'ACCResumeBtn': 1}),
packer.make_can_msg('PSCM1', 0, {'SteeringAngleServo': 12.5, 'LKATorque': torque}),
packer.make_can_msg('PedalandBrake', 0, {'AccPedal': 5.1, 'BrakePedalActive': 1}),
packer.make_can_msg('TCM0', 0, {'GearShifter': 3}),
packer.make_can_msg('ACC', 0, {'SpeedTargetACC': 100}),
packer.make_can_msg('MiscCarInfo', 0, {'TurnSignal': 1}),
packer.make_can_msg('FSM0', 2, {'ACCStatusOnOff': 1, 'ACCStatusActive': 1}),
packer.make_can_msg('FSM1', 2, {}),
]
for parser in parsers.values():
parser.update([(time, messages)])
class TestVolvoC1Host(unittest.TestCase):
def test_c1_params_preserve_stock_long(self):
for alpha in [False, True]:
for release in [False, True]:
with self.subTest(alpha=alpha, release=release):
cp = params(alpha, release)
assert (cp.safetyConfigs[0].safetyModel, cp.safetyConfigs[0].safetyParam) == (structs.CarParams.SafetyModel.volvo, 2)
assert not cp.openpilotLongitudinalControl and (not cp.alphaLongitudinalAvailable)
assert cp.pcmCruise and cp.radarUnavailable
assert cp.steerControlType == structs.CarParams.SteerControlType.angle
assert cp.minSteerSpeed == pytest.approx(1 / 3.6)
assert cp.steerActuatorDelay == pytest.approx(0.2)
def test_c1_required_can_and_state_contract(self):
cp = params()
cs = CarState(cp)
parsers = cs.get_can_parsers(cp)
feed(parsers)
assert all(parser.can_valid for parser in parsers.values())
ret = cs.update(parsers)
assert ret.vEgoRaw == pytest.approx(20)
assert ret.steeringAngleDeg == pytest.approx(12.5, abs=0.03)
assert ret.steeringTorque == 10
assert ret.gasPressed and ret.brakePressed
assert ret.gearShifter == structs.CarState.GearShifter.drive
assert ret.cruiseState.available and ret.cruiseState.enabled
assert ret.cruiseState.speed == pytest.approx(100 / 3.6)
assert ret.leftBlinker and (not ret.rightBlinker)
assert len(ret.buttonEvents) == 1 and ret.buttonEvents[0].pressed
assert not ret.steeringPressed and (not ret.doorOpen) and (not ret.seatbeltUnlatched)
assert not ret.steerFaultTemporary and (not ret.steerFaultPermanent)
for index in range(6):
for parser in parsers.values():
parser.update([(3000000000 + index * 10000000, [])])
_ = parser.can_valid
assert not all(parser.can_valid for parser in parsers.values())
def test_c1_recovery_and_cancel_cadence_are_distinct(self):
cp = params()
cs = CarState(cp)
parsers = cs.get_can_parsers(cp)
feed(parsers, torque=0)
cs.out = cs.update(parsers)
controller = CarController(DBC[CAR.VOLVO_V40], cp)
cc = structs.CarControl.new_message()
cc.latActive = True
cc.cruiseControl.cancel = True
cc.actuators.steeringAngleDeg = 12.5
decoder = CANParser('volvo_v40_2017_pt', [('FSM1', 0), ('CCButtons', 0)], 0)
types = []
cancel_frames = []
for frame in range(124):
_, messages = controller.update(cc.as_reader(), cs, frame * 10000000)
if frame % 2 == 0:
assert {msg[0] for msg in messages if msg[0] != 16} == {208, 293}
decoder.update([(1000000000 + frame * 10000000, messages)])
types.append((frame, decoder.vl['FSM1']['LKASteerDirection']))
else:
assert not any(msg[0] in (208, 293) for msg in messages)
if any(msg[0] == 16 for msg in messages):
cancel_frames.append(frame)
assert cancel_frames == list(range(0, 124, 10))
assert all((direction == 3 for frame, direction in types if frame < 22))
assert all((direction == 0 for frame, direction in types if 22 <= frame < 122))
assert types[-1] == (122, 3)
assert not cp.openpilotLongitudinalControl
def test_c1_complete_fingerprint_identifies_unique_vehicle(self):
for capture in range(3):
with self.subTest(capture=capture):
from types import SimpleNamespace
from opendbc.car.fingerprints import all_legacy_fingerprint_cars, eliminate_incompatible_cars
from opendbc.car.volvo.fingerprints import FINGERPRINTS
candidates = all_legacy_fingerprint_cars()
for address, size in FINGERPRINTS[CAR.VOLVO_V40][capture].items():
candidates = eliminate_incompatible_cars(SimpleNamespace(address=address, dat=bytes(size)), candidates)
assert candidates == [CAR.VOLVO_V40]
assert not eliminate_incompatible_cars(SimpleNamespace(address=293, dat=bytes(7)), [CAR.VOLVO_V40])
def test_host_output_matches_native_sign_relay_and_limits(self):
for active in [False, True]:
with self.subTest(active=active):
from opendbc.safety.tests import test_volvo_c1 as native
cp = params()
cs = CarState(cp)
parsers = cs.get_can_parsers(cp)
feed(parsers, torque=10)
cs.out = cs.update(parsers)
controller = CarController(DBC[CAR.VOLVO_V40], cp)
cc = structs.CarControl.new_message()
cc.latActive = active
cc.cruiseControl.cancel = True
cc.actuators.steeringAngleDeg = 15
native.reset()
native.lib.set_angle_meas(284, 284)
native.lib.set_desired_angle_last(284)
for _ in range(6):
native.rx('VehicleSpeed1', 0, {'VehicleSpeed': 72})
native.lib.set_controls_allowed(True)
for frame in range(100):
cc.latActive = active and frame >= 2
_, messages = controller.update(cc.as_reader(), cs, frame * 10000000)
for message in messages:
assert native.lib.safety_tx_hook(native.packet(message)), (active, frame, message)
+46
View File
@@ -0,0 +1,46 @@
from dataclasses import dataclass, field
from enum import IntFlag
from opendbc.car import Bus, CarSpecs, DbcDict, PlatformConfig, Platforms
from opendbc.car.structs import CarParams
from opendbc.car.common.conversions import Conversions as CV
from opendbc.car.docs_definitions import CarDocs, CarHarness, CarParts
from opendbc.car.fw_query_definitions import FwQueryConfig
from opendbc.car.lateral import AngleSteeringLimits
# C1 support is adapted from the original dragonpilot V40 port:
# https://github.com/dragonpilot/dragonpilot/commit/773dce507082d931236b64dca8024dce9625446f
Ecu = CarParams.Ecu
class VolvoSafetyFlags(IntFlag):
C1 = 2
class CarControllerParams:
C1_N_ZERO_TORQUE = 12
C1_ANGLE_ERROR = 20.0
C1_ANGLE_DELTA_BP = [0.0, 8.33, 13.89, 19.44, 25.0, 30.55, 36.1]
C1_ANGLE_DELTA_UP = [2.0, 1.2, 0.25, 0.20, 0.15, 0.10, 0.10]
C1_ANGLE_DELTA_DOWN = [2.0, 1.2, 0.25, 0.20, 0.15, 0.10, 0.10]
C1_ANGLE_LIMITS = AngleSteeringLimits(359.9, (C1_ANGLE_DELTA_BP, C1_ANGLE_DELTA_UP), (C1_ANGLE_DELTA_BP, C1_ANGLE_DELTA_DOWN))
@dataclass
class VolvoCarDocs(CarDocs):
package: str = "Pilot Assist & Adaptive Cruise Control"
car_parts: CarParts = field(default_factory=lambda: CarParts.common([CarHarness.custom]))
@dataclass
class VolvoC1PlatformConfig(PlatformConfig):
dbc_dict: DbcDict = field(default_factory=lambda: {Bus.pt: 'volvo_v40_2017_pt', Bus.cam: 'volvo_v40_2017_pt'})
class CAR(Platforms):
VOLVO_V40 = VolvoC1PlatformConfig(
[VolvoCarDocs("Volvo V40 2013-19")], CarSpecs(mass=1610, wheelbase=2.647, steerRatio=14.7, centerToFrontRatio=0.44, minSteerSpeed=1.0 * CV.KPH_TO_MS)
)
FW_QUERY_CONFIG = FwQueryConfig(requests=[], fw_version_regex=br"(?!)")
DBC = CAR.create_dbc_map()
@@ -0,0 +1,38 @@
def create_c1_pscm_message(packer, msg_pscm: dict):
values = {
"LKATorque": 0,
"SteeringAngleServo": msg_pscm["SteeringAngleServo"],
"byte0": msg_pscm["byte0"],
"byte3": msg_pscm["byte3"],
"byte4": msg_pscm["byte4"],
"byte7": msg_pscm["byte7"],
"LKAActive": int(msg_pscm["LKAActive"]) & 0xD,
}
return packer.make_can_msg("PSCM1", 2, values)
def create_c1_checksum(data: bytes) -> int:
angle_raw = ((data[4] & 0x3F) << 8) | data[5]
direction = data[7] & 0x3
checksum_sum = (data[3] + direction + angle_raw + (angle_raw >> 8)) & 0xFF
return checksum_sum ^ 0xFF
def create_c1_steering_control(packer, apply_angle: float, lat_active: bool):
values = {
"SET_X_E3": 0xE3,
"SET_X_B4": 0xB4,
"SET_X_08": 0x08,
"TrqLim": 0,
"LKAAngleReq": apply_angle,
"LKASteerDirection": 3 if lat_active else 0,
"SET_X_25": 0x25,
"SET_X_02": 0x02,
}
data = packer.make_can_msg("FSM1", 0, values)[1]
values["Checksum"] = create_c1_checksum(data)
return packer.make_can_msg("FSM1", 0, values)
def create_c1_cancel(packer):
return packer.make_can_msg("CCButtons", 0, {"ACCStopBtn": 1})
@@ -0,0 +1,191 @@
#pragma once
#include "opendbc/safety/declarations.h"
#define VOLVO_C1_BUTTONS 0x10U
#define VOLVO_C1_FSM_0 0x30U
#define VOLVO_C1_FSM_1 0xD0U
#define VOLVO_C1_PSCM_1 0x125U
#define VOLVO_C1_PEDAL_AND_BRAKE 0x55U
#define VOLVO_C1_SPEED 0x150U
#define VOLVO_MAIN_BUS 0U
#define VOLVO_PARTY_BUS 2U
#define VOLVO_C1_ANGLE_DEG_TO_CAN 22.753128F
#define VOLVO_C1_MAX_ANGLE_CAN 8189
#define VOLVO_C1_RELAY_ANGLE_TOLERANCE 2
static int volvo_c1_pscm_angle(const CANPacket_t *msg) {
const uint32_t raw_angle = ((uint32_t)msg->data[5] << 8U) | (uint32_t)msg->data[6];
return (int)raw_angle - 32768;
}
static int volvo_c1_fsm_angle(const CANPacket_t *msg) {
const uint32_t raw_angle = (((uint32_t)msg->data[4] & 0x3FU) << 8U) | (uint32_t)msg->data[5];
return (int)raw_angle - 8192;
}
static uint8_t volvo_c1_fsm_checksum(const CANPacket_t *msg) {
const unsigned int angle_raw = (((unsigned int)msg->data[4] & 0x3FU) << 8U) | (unsigned int)msg->data[5];
const unsigned int direction = (unsigned int)msg->data[7] & 0x3U;
const unsigned int checksum_sum = ((unsigned int)msg->data[3] + direction + angle_raw + (angle_raw >> 8U)) & 0xFFU;
const unsigned int inverted_checksum = checksum_sum ^ 0xFFU;
return (uint8_t)inverted_checksum;
}
static void volvo_c1_rx_hook(const CANPacket_t *msg) {
if (msg->bus == VOLVO_MAIN_BUS) {
if (msg->addr == VOLVO_C1_PSCM_1) {
update_sample(&angle_meas, volvo_c1_pscm_angle(msg));
}
if (msg->addr == VOLVO_C1_SPEED) {
const uint16_t speed_raw = ((uint16_t)msg->data[6] << 8U) | msg->data[7];
const float speed = ((float)speed_raw * 0.01f) / 3.6f;
vehicle_moving = speed > 0.1f;
UPDATE_VEHICLE_SPEED(speed);
}
if (msg->addr == VOLVO_C1_PEDAL_AND_BRAKE) {
const unsigned int gas_raw = (((unsigned int)msg->data[1] & 0x3U) << 8U) | (unsigned int)msg->data[2];
gas_pressed = gas_raw > 50U; // DBC factor 0.1: greater than 5 percent
brake_pressed = GET_BIT(msg, 24U) || GET_BIT(msg, 38U);
}
}
if ((msg->bus == VOLVO_PARTY_BUS) && (msg->addr == VOLVO_C1_FSM_0)) {
pcm_cruise_check(GET_BIT(msg, 58U));
}
}
// C1 retains its measured-angle EPS envelope; the shared angle API no longer supplies it.
static bool volvo_c1_angle_cmd_checks(int desired_angle, bool steer_control_enabled, const AngleSteeringLimits limits) {
bool violation = false;
if (lateral_controls_allowed() && steer_control_enabled) {
// convert floating point angle rate limits to integers in the scale of the desired angle on CAN,
// add 1 to not false trigger the violation. also fudge the speed by 1 m/s so rate limits are
// always slightly above openpilot's in case we read an updated speed in between angle commands
// TODO: this speed fudge can be much lower, look at data to determine the lowest reasonable offset
const float fudged_speed = (vehicle_speed.min / VEHICLE_SPEED_FACTOR) - 1.;
int delta_angle_up = (safety_interpolate(limits.angle_rate_up_lookup, fudged_speed) * limits.angle_deg_to_can) + 1.;
int delta_angle_down = (safety_interpolate(limits.angle_rate_down_lookup, fudged_speed) * limits.angle_deg_to_can) + 1.;
// allow down limits at zero since small floats from openpilot will be rounded to 0
// TODO: openpilot should be cognizant of this and not send small floats
int highest_desired_angle = desired_angle_last + ((desired_angle_last > 0) ? delta_angle_up : delta_angle_down);
int lowest_desired_angle = desired_angle_last - ((desired_angle_last >= 0) ? delta_angle_down : delta_angle_up);
// check that commanded angle value isn't too far from measured, used to limit torque for some safety modes
// ensure we start moving in direction of meas while respecting relaxed rate limits if error is exceeded
if (((vehicle_speed.values[0] / VEHICLE_SPEED_FACTOR) > 0.0F)) {
// flipped fudge to avoid false positives
const float fudged_speed_error = (vehicle_speed.max / VEHICLE_SPEED_FACTOR) + 1.;
const int delta_angle_up_relaxed = (safety_interpolate(limits.angle_rate_up_lookup, fudged_speed_error) * limits.angle_deg_to_can) - 1.;
const int delta_angle_down_relaxed = (safety_interpolate(limits.angle_rate_down_lookup, fudged_speed_error) * limits.angle_deg_to_can) - 1.;
// the minimum and maximum angle allowed based on the measured angle
const int lowest_desired_angle_error = angle_meas.min - 455 - 1;
const int highest_desired_angle_error = angle_meas.max + 455 + 1;
// the MAX is to allow the desired angle to hit the edge of the bounds and not require going under it
if (desired_angle_last > highest_desired_angle_error) {
const int delta = (desired_angle_last >= 0) ? delta_angle_down_relaxed : delta_angle_up_relaxed;
highest_desired_angle = SAFETY_MAX(desired_angle_last - delta, highest_desired_angle_error);
} else if (desired_angle_last < lowest_desired_angle_error) {
const int delta = (desired_angle_last <= 0) ? delta_angle_down_relaxed : delta_angle_up_relaxed;
lowest_desired_angle = SAFETY_MIN(desired_angle_last + delta, lowest_desired_angle_error);
} else {
// already inside error boundary, don't allow commanding outside it
highest_desired_angle = SAFETY_MIN(highest_desired_angle, highest_desired_angle_error);
lowest_desired_angle = SAFETY_MAX(lowest_desired_angle, lowest_desired_angle_error);
}
// don't enforce above the max steer
// TODO: this should always be done
lowest_desired_angle = SAFETY_CLAMP(lowest_desired_angle, -limits.max_angle, limits.max_angle);
highest_desired_angle = SAFETY_CLAMP(highest_desired_angle, -limits.max_angle, limits.max_angle);
}
// check for violation;
violation |= safety_max_limit_check(desired_angle, highest_desired_angle, lowest_desired_angle);
}
desired_angle_last = desired_angle;
// Angle should be close to current angle while not steering
if (!steer_control_enabled) {
violation |= steer_angle_cmd_inactive_check(desired_angle, limits.max_angle);
}
// No angle control allowed when controls are not allowed
if (!lateral_controls_allowed()) {
violation |= steer_control_enabled;
}
// reset to current angle if either controls is not allowed or there's a violation
if (violation || !lateral_controls_allowed()) {
desired_angle_last = SAFETY_CLAMP(angle_meas.values[0], -limits.max_angle, limits.max_angle);
}
return violation;
}
static bool volvo_c1_tx_hook(const CANPacket_t *msg) {
static const AngleSteeringLimits VOLVO_C1_ANGLE_STEERING_LIMITS = {
.max_angle = VOLVO_C1_MAX_ANGLE_CAN,
.angle_deg_to_can = VOLVO_C1_ANGLE_DEG_TO_CAN,
.angle_rate_up_lookup = {
{7.0f, 17.0f, 36.0f},
{2.0f, 0.25f, 0.1f},
},
.angle_rate_down_lookup = {
{7.0f, 17.0f, 36.0f},
{2.0f, 0.25f, 0.1f},
},
.frequency = 50U,
};
bool tx = true;
if (msg->addr == VOLVO_C1_FSM_1) {
const int desired_angle = volvo_c1_fsm_angle(msg);
const uint8_t direction = msg->data[7] & 0x3U;
const bool steer_control_enabled = direction != 0U;
tx &= SAFETY_ABS(desired_angle) <= VOLVO_C1_MAX_ANGLE_CAN;
tx &= !volvo_c1_angle_cmd_checks(desired_angle, steer_control_enabled, VOLVO_C1_ANGLE_STEERING_LIMITS);
tx &= (direction == 0U) || (direction == 3U);
tx &= (msg->data[0] == 0xE3U) && (msg->data[1] == 0xB4U) && (msg->data[2] == 0x08U);
tx &= (msg->data[3] == 0x80U) && ((msg->data[4] & 0xC0U) == 0x80U) && ((msg->data[7] & 0xFCU) == 0x94U);
tx &= msg->data[6] == volvo_c1_fsm_checksum(msg);
}
if (msg->addr == VOLVO_C1_PSCM_1) {
const int relayed_angle = volvo_c1_pscm_angle(msg);
const uint32_t torque_raw = (((uint32_t)msg->data[1] & 0x0FU) << 8U) | (uint32_t)msg->data[2];
tx &= torque_raw == 2000U;
tx &= (msg->data[1] & 0x20U) == 0U;
const int measured_max = angle_meas.max + VOLVO_C1_RELAY_ANGLE_TOLERANCE;
const int measured_min = angle_meas.min - VOLVO_C1_RELAY_ANGLE_TOLERANCE;
tx &= !safety_max_limit_check(relayed_angle, measured_max, measured_min);
}
// Only ACC cancel (byte 7 bit 4) may be synthesized.
if (msg->addr == VOLVO_C1_BUTTONS) {
for (unsigned int i = 0U; i < 6U; i++) {
tx &= msg->data[i] == 0U;
}
tx &= ((msg->data[7] & 0xEFU) == 0U) && (msg->data[6] == 0U);
}
return tx;
}
static safety_config volvo_c1_init(uint16_t param) {
(void)param;
static const CanMsg VOLVO_C1_TX_MSGS[] = {
{VOLVO_C1_FSM_1, VOLVO_MAIN_BUS, 8, .check_relay = true},
{VOLVO_C1_PSCM_1, VOLVO_PARTY_BUS, 8, .check_relay = true},
{VOLVO_C1_BUTTONS, VOLVO_MAIN_BUS, 8, .check_relay = false},
};
static RxCheck volvo_c1_rx_checks[] = {
{.msg = {{VOLVO_C1_PSCM_1, VOLVO_MAIN_BUS, 8, 50U, .ignore_checksum = true, .ignore_counter = true, .ignore_quality_flag = true}, { 0 }, { 0 }}},
{.msg = {{VOLVO_C1_FSM_0, VOLVO_PARTY_BUS, 8, 100U, .ignore_checksum = true, .ignore_counter = true, .ignore_quality_flag = true}, { 0 }, { 0 }}},
{.msg = {{VOLVO_C1_PEDAL_AND_BRAKE, VOLVO_MAIN_BUS, 8, 100U, .ignore_checksum = true, .ignore_counter = true, .ignore_quality_flag = true}, { 0 }, { 0 }}},
{.msg = {{VOLVO_C1_SPEED, VOLVO_MAIN_BUS, 8, 50U, .ignore_checksum = true, .ignore_counter = true, .ignore_quality_flag = true}, { 0 }, { 0 }}},
};
return BUILD_SAFETY_CFG(volvo_c1_rx_checks, VOLVO_C1_TX_MSGS);
}
const safety_hooks volvo_c1_hooks = {.init = volvo_c1_init, .rx = volvo_c1_rx_hook, .tx = volvo_c1_tx_hook};
+8
View File
@@ -11,6 +11,7 @@
#include "opendbc/safety/modes/honda.h"
#include "opendbc/safety/modes/toyota.h"
#include "opendbc/safety/modes/tesla.h"
#include "opendbc/safety/modes/volvo_c1.h"
#include "opendbc/safety/modes/tesla_hw1.h"
#include "opendbc/safety/modes/gm.h"
#include "opendbc/safety/modes/ford.h"
@@ -481,6 +482,7 @@ int set_safety_hooks(uint16_t mode, uint16_t param) {
{SAFETY_FORD, &ford_hooks},
{SAFETY_RIVIAN, &rivian_hooks},
{SAFETY_TESLA, &tesla_hooks},
{SAFETY_VOLVO, &volvo_c1_hooks},
{SAFETY_HYUNDAI_CANFD, &hyundai_canfd_hooks},
#ifdef ALLOW_DEBUG
{SAFETY_MG, &mg_hooks},
@@ -544,6 +546,12 @@ int set_safety_hooks(uint16_t mode, uint16_t param) {
int hook_config_count = sizeof(safety_hook_registry) / sizeof(safety_hook_config);
for (int i = 0; i < hook_config_count; i++) {
if (safety_hook_registry[i].id == mode) {
if ((mode == SAFETY_VOLVO) && (param != 2U)) {
current_hooks = &nooutput_hooks;
current_safety_mode = SAFETY_NOOUTPUT;
current_safety_param = 0;
continue;
}
if ((mode == SAFETY_TESLA) && ((param & ~(TESLA_HW1_FLAG | 1U)) != 0U)) {
current_hooks = &nooutput_hooks;
current_safety_mode = SAFETY_NOOUTPUT;
@@ -0,0 +1,234 @@
import unittest
import pytest
from opendbc.can import CANPacker
from opendbc.safety import LEN_TO_DLC
from opendbc.safety.tests.libsafety import libsafety_py
from opendbc.car.volvo import volvocan as codec
ffi = libsafety_py.ffi
lib = libsafety_py.libsafety
packer = CANPacker('volvo_v40_2017_pt')
def packet(message):
address, data, bus = message
p = libsafety_py.new_CANPacket()
p.addr = address
p.bus = bus
p.data_len_code = LEN_TO_DLC[len(data)]
p.data = bytes(data)
return p
def reset():
assert lib.set_safety_hooks(36, 2) == 0
lib.set_timer(0)
def rx(name, bus, values):
return lib.safety_rx_hook(packet(packer.make_can_msg(name, bus, values)))
class TestVolvoC1Safety(unittest.TestCase):
def test_exhaustive_profile_only_c1_is_admitted(self):
for param in range(65536):
status = lib.set_safety_hooks(36, param)
assert status == (0 if param == 2 else -1)
if param != 2:
lib.set_controls_allowed(True)
assert not lib.safety_tx_hook(packet(codec.create_c1_cancel(packer)))
def test_wrong_bus_and_length(self):
for kind in ['steer', 'relay', 'cancel']:
for bus in range(8):
with self.subTest(kind=kind, bus=bus):
reset()
lib.set_angle_meas(0, 0)
message = (
codec.create_c1_steering_control(packer, 0, False)
if kind == 'steer'
else codec.create_c1_pscm_message(packer, {'SteeringAngleServo': 0, 'byte0': 0, 'byte3': 0, 'byte4': 0, 'byte7': 0, 'LKAActive': 0})
if kind == 'relay'
else codec.create_c1_cancel(packer)
)
address, data, expected_bus = message
assert bool(lib.safety_tx_hook(packet((address, data, bus)))) == (bus == expected_bus)
for length in range(8):
assert not lib.safety_tx_hook(packet((address, data[:length], bus)))
def test_literal_rx_speed_angle_gas_brake_cruise(self):
reset()
assert rx('VehicleSpeed1', 0, {'VehicleSpeed': 72})
assert lib.get_vehicle_speed_max() == pytest.approx(20)
assert rx('PSCM1', 0, {'SteeringAngleServo': 12.5})
assert lib.get_angle_meas_max() == 284
rx('PedalandBrake', 0, {'AccPedal': 5.1})
assert lib.get_gas_pressed_prev()
rx('PedalandBrake', 0, {'AccPedal': 5})
assert not lib.get_gas_pressed_prev()
for name in ('BrakePedalActive', 'BrakePedalActive2'):
rx('PedalandBrake', 0, {name: 1})
assert lib.get_brake_pressed_prev()
rx('PedalandBrake', 0, {})
assert not lib.get_brake_pressed_prev()
rx('FSM0', 2, {'ACCStatusActive': 1})
assert lib.get_controls_allowed()
rx('FSM0', 2, {'ACCStatusActive': 0})
assert not lib.get_controls_allowed()
def test_positive_feed_then_required_rx_loss(self):
for missing in ['PSCM1', 'FSM0', 'PedalandBrake', 'VehicleSpeed1']:
with self.subTest(missing=missing):
reset()
for index in range(1, 221):
lib.set_timer(index * 10000)
for name, bus, values in [
('PSCM1', 0, {'SteeringAngleServo': 0}),
('FSM0', 2, {'ACCStatusActive': 1}),
('PedalandBrake', 0, {}),
('VehicleSpeed1', 0, {'VehicleSpeed': 72}),
]:
if index <= 20 or name != missing:
assert rx(name, bus, values)
lib.safety_tick()
if index == 20:
assert lib.safety_config_valid() and lib.get_controls_allowed()
assert not lib.safety_config_valid() and (not lib.get_controls_allowed())
def test_stock_forwarding_and_cancel_only_fields(self):
reset()
for bus in (0, 2):
for address in (16, 48, 208, 293, 85, 336):
blocked = bus == 2 and address == 208 or (bus == 0 and address == 293)
assert lib.safety_fwd_hook(bus, address) == (-1 if blocked else 2 - bus)
assert lib.safety_tx_hook(packet(codec.create_c1_cancel(packer)))
for name in ['ACCOnOffBtn', 'ACCSetBtn', 'ACCResumeBtn', 'ACCMinusBtn', 'TimeGapIncreaseBtn', 'TimeGapDecreaseBtn']:
assert not lib.safety_tx_hook(packet(packer.make_can_msg('CCButtons', 0, {name: 1})))
def test_fixed_payload_checksum_direction_and_inactive_neutral(self):
reset()
lib.set_angle_meas(0, 0)
good = codec.create_c1_steering_control(packer, 0, False)
assert lib.safety_tx_hook(packet(good))
assert not lib.safety_tx_hook(packet(codec.create_c1_steering_control(packer, 10, False)))
for index in range(8):
reset()
lib.set_angle_meas(0, 0)
changed = bytearray(good[1])
changed[index] ^= 64
assert not lib.safety_tx_hook(packet((good[0], changed, good[2])))
for direction in (1, 2):
data = bytearray(good[1])
data[7] = data[7] & ~3 | direction
data[6] = codec.create_c1_checksum(data)
assert not lib.safety_tx_hook(packet((good[0], data, good[2])))
def test_measured_angle_error_converges_with_original_envelope(self):
reset()
lib.set_controls_allowed(True)
lib.set_angle_meas(0, 0)
for _ in range(6):
rx('VehicleSpeed1', 0, {'VehicleSpeed': 72})
lib.set_controls_allowed(True)
for degrees in (1, 10, 20):
lib.set_desired_angle_last(round(degrees * 22.753128))
assert lib.safety_tx_hook(packet(codec.create_c1_steering_control(packer, degrees, True)))
lib.set_desired_angle_last(round(30 * 22.753128))
assert not lib.safety_tx_hook(packet(codec.create_c1_steering_control(packer, 30, True)))
lib.set_controls_allowed(True)
lib.set_desired_angle_last(round(30 * 22.753128))
assert lib.safety_tx_hook(packet(codec.create_c1_steering_control(packer, 29.8, True)))
lib.set_controls_allowed(True)
lib.set_desired_angle_last(8189)
assert not lib.safety_tx_hook(packet(codec.create_c1_steering_control(packer, 360, True)))
def test_relay_malfunction_blocks_tx_and_forward(self):
for bus, address in [(0, 208), (2, 293)]:
with self.subTest(bus=bus, address=address):
reset()
lib.init_tests()
assert not lib.get_relay_malfunction()
lib.safety_rx_hook(packet((address, bytes(8), bus)))
assert lib.get_relay_malfunction()
assert not lib.safety_tx_hook(packet(codec.create_c1_cancel(packer)))
assert lib.safety_fwd_hook(0, 336) == -1
assert lib.safety_fwd_hook(2, 336) == -1
def test_relay_angle_cannot_depart_from_measured_sample(self):
reset()
lib.set_angle_meas(100, 100)
for raw in (98, 100, 102):
data = bytearray(8)
data[1] = 7
data[2] = 208
encoded = raw + 32768
data[5] = encoded >> 8
data[6] = encoded & 255
assert lib.safety_tx_hook(packet((293, data, 2)))
for raw in (97, 103):
data = bytearray(8)
data[1] = 7
data[2] = 208
encoded = raw + 32768
data[5] = encoded >> 8
data[6] = encoded & 255
assert not lib.safety_tx_hook(packet((293, data, 2)))
def test_signed_measurement_and_neutral_command_share_scale(self):
for degrees, raw in [(12.5, 284), (-12.5, -284)]:
with self.subTest(degrees=degrees, raw=raw):
reset()
for _ in range(6):
assert rx('PSCM1', 0, {'SteeringAngleServo': degrees})
assert lib.get_angle_meas_min() == lib.get_angle_meas_max() == raw
assert lib.safety_tx_hook(packet(codec.create_c1_steering_control(packer, degrees, False)))
def test_original_pedal_permission_contract(self):
for values, allowed in [({'AccPedal': 5.1}, True), ({'BrakePedalActive': 1}, False), ({'BrakePedalActive2': 1}, False)]:
with self.subTest(values=values, allowed=allowed):
reset()
rx('VehicleSpeed1', 0, {'VehicleSpeed': 72})
lib.set_controls_allowed(True)
rx('PedalandBrake', 0, values)
assert bool(lib.get_controls_allowed()) == allowed
def test_relay_zero_torque_and_mask_are_enforced(self):
for active in range(16):
with self.subTest(active=active):
reset()
lib.set_angle_meas(0, 0)
stock = {'SteeringAngleServo': 0, 'byte0': 17, 'byte3': 29, 'byte4': 43, 'byte7': 57, 'LKAActive': active}
message = codec.create_c1_pscm_message(packer, stock)
assert lib.safety_tx_hook(packet(message))
for torque in (1999, 2001, 0, 4095):
data = bytearray(message[1])
data[1] = data[1] & 240 | torque >> 8
data[2] = torque & 255
assert not lib.safety_tx_hook(packet((293, data, 2)))
data = bytearray(message[1])
data[1] |= 32
assert not lib.safety_tx_hook(packet((293, data, 2)))
assert message[1][0] == 17 and message[1][3] == 29 and (message[1][4] == 43) and (message[1][7] == 57)
def test_cancel_opaque_prefix_is_pinned_to_host_zero_template(self):
for index in range(6):
with self.subTest(index=index):
reset()
message = codec.create_c1_cancel(packer)
assert message[1] == bytes([0, 0, 0, 0, 0, 0, 0, 16])
assert lib.safety_tx_hook(packet(message))
data = bytearray(message[1])
data[index] = 1
assert not lib.safety_tx_hook(packet((message[0], data, message[2])))
def test_negative_measured_error_requires_convergence_with_same_envelope(self):
reset()
lib.set_angle_meas(0, 0)
for _ in range(6):
rx('VehicleSpeed1', 0, {'VehicleSpeed': 72})
lib.set_controls_allowed(True)
lib.set_desired_angle_last(round(-30 * 22.753128))
self.assertFalse(lib.safety_tx_hook(packet(codec.create_c1_steering_control(packer, -30, True))))
lib.set_controls_allowed(True)
lib.set_desired_angle_last(round(-30 * 22.753128))
self.assertTrue(lib.safety_tx_hook(packet(codec.create_c1_steering_control(packer, -29.8, True))))
@@ -34,6 +34,42 @@ class TestVehicleSelection(unittest.TestCase):
self.assertEqual(len(selected), 1)
self.assertEqual(selected[0].label, 'Hyundai Ioniq 6')
def test_volt_harness_choices_are_under_chevrolet(self):
from opendbc.car.gm.values import CAR as GM
expected = {
GM.CHEVROLET_VOLT: 'Chevrolet Volt (OBD-II Harness) 2017-18',
GM.CHEVROLET_VOLT_ASCM: 'Chevrolet Volt (ASCM Harness) 2017-18',
GM.CHEVROLET_VOLT_2019: 'Chevrolet Volt (SDGM Harness) 2019',
GM.CHEVROLET_VOLT_CAMERA: 'Chevrolet Volt (Camera Harness)',
GM.CHEVROLET_VOLT_CC: 'Chevrolet Volt (CC)',
}
catalog = {choice.platform: choice for choice in self.owner.choices()}
for platform, label in expected.items():
with self.subTest(platform=platform):
self.assertEqual(catalog[platform].make, 'Chevrolet')
self.assertEqual(catalog[platform].label, label)
original = self.owner.snapshot()
self.assertTrue(self.owner.choose(original.raw, str(platform)).verified)
self.assertEqual(startup_candidate(self.params), platform)
def test_k4_2026_documentation_keeps_one_manual_platform_and_factory(self):
from opendbc.car.hyundai.interface import CarInterface
from opendbc.car.values import PLATFORMS
labels = {doc.name for doc in PLATFORMS[CAR.KIA_K4_2025].config.car_docs}
self.assertEqual(labels, {'Kia K4 (without HDA II) 2025', 'Kia K4 (without HDA II) 2026',
'Kia K4 (with HDA II) 2025'})
selected = [choice for choice in self.owner.choices() if choice.platform == CAR.KIA_K4_2025]
self.assertEqual(len(selected), 1)
self.assertTrue(self.owner.choose(None, selected[0].platform).committed)
self.assertEqual(startup_candidate(self.params), CAR.KIA_K4_2025)
candidate = startup_candidate(self.params)
assert candidate is not None
cp = CarInterface.get_params(candidate, gen_empty_fingerprint(), [], False, False, False)
self.assertEqual(cp.carFingerprint, CAR.KIA_K4_2025)
self.assertFalse(cp.openpilotLongitudinalControl)
def test_strict_document_and_corrupt_source_preserved_until_explicit_repair(self):
for bad in (b'{}', b'{"version":true,"platform":null}', b'{"version":1,"platform":"FAKE"}',
b'{"version":1,"platform":null,"platform":"KIA_XCEED_PHEV"}', b'\xff'):
+4 -4
View File
@@ -1,4 +1,4 @@
"""One parked, source-bound vehicle choice; Auto retains native fingerprinting."""
"""One source-bound vehicle choice; Auto retains native fingerprinting."""
from collections.abc import Callable
from dataclasses import dataclass
@@ -88,9 +88,9 @@ def startup_candidate(params, *, developer_fingerprint: bool = False) -> str | N
class VehicleSelectionOwner:
def __init__(self, params, parked: Callable[[], bool]):
def __init__(self, params, authorized: Callable[[], bool]):
self.params = params
self.parked = parked
self.authorized = authorized
def snapshot(self) -> SelectionSnapshot:
return read_selection(self.params)
@@ -104,5 +104,5 @@ class VehicleSelectionOwner:
except ValueError:
return WriteResult(False, False)
return commit_exact(self.params, key=KEY, max_bytes=MAX_BYTES, raw=desired,
expected=expected_raw, authorized=self.parked,
expected=expected_raw, authorized=self.authorized,
temp_prefix='.vehicle-selection-')
+60 -11
View File
@@ -18,7 +18,7 @@ SHA256 = re.compile(r"[0-9a-f]{64}\Z")
REVISION = re.compile(r"[0-9a-f]{40}\Z")
NATIVE_CAN_SOURCES = {"opendbc_repo/opendbc/can/packer.py", "opendbc_repo/opendbc/can/parser.py"}
RELEVANT_SOURCE_PATHS = ("opendbc_repo", "openpilot/common", "openpilot/cereal", "msgq_repo",
"tools/ci/run_vehicle_tests.py", "tools/test_runner.py", "upstream-sync.json")
"tools/ci/run_vehicle_tests.py", "tools/ci/vehicle_pytest.py", "tools/test_runner.py", "upstream-sync.json")
def sha256(path):
@@ -43,6 +43,27 @@ def _relevant_status_lines(status):
line[3:] == path or line[3:].startswith(path + "/") for path in RELEVANT_SOURCE_PATHS)]
def source_snapshot_matches(source, source_after, current):
from tools.ci.run_vehicle_tests import SOURCE_INPUT_PATHS
if not all(isinstance(value, dict) for value in (source, source_after, current)):
return False
before = source.get("inputs")
after = source_after.get("inputs")
if not isinstance(before, dict) or before != after or before != current:
return False
if set(before) != {"revision", "paths", "tracked_patch_sha256", "untracked_sha256"}:
return False
if (not REVISION.fullmatch(str(before["revision"])) or before["paths"] != list(SOURCE_INPUT_PATHS) or
not SHA256.fullmatch(str(before["tracked_patch_sha256"])) or
not isinstance(before["untracked_sha256"], dict)):
return False
if any(not isinstance(path, str) or not path or not SHA256.fullmatch(str(digest))
for path, digest in before["untracked_sha256"].items()):
return False
return source.get("revision") == source_after.get("revision") == before["revision"]
def _ids(values, label, errors):
if not isinstance(values, list) or any(not isinstance(v, str) or not v for v in values):
errors.append(f"{label}: expected nonempty string IDs")
@@ -53,7 +74,8 @@ def _ids(values, label, errors):
return values
def evaluate(manifest, coverage, results, mode_evidence=None, *, expected_revision=None, hashes=None, dirty_relevant=None):
def evaluate(manifest, coverage, results, mode_evidence=None, *, expected_revision=None, hashes=None, dirty_relevant=None,
current_snapshot=None, expected_registry=None):
"""Check independent registration, test execution and per-config mode evidence.
All input documents are caller supplied; their physical origin is not asserted.
@@ -73,8 +95,6 @@ def evaluate(manifest, coverage, results, mode_evidence=None, *, expected_revisi
mode_evidence = {}
if dirty_relevant is None:
dirty_relevant = relevant_dirty_paths()
if dirty_relevant:
uncovered.append("relevant vehicle/runner source differs from committed checkout")
platforms = manifest.get("platforms")
if manifest.get("schema_version") != 1 or not isinstance(platforms, list):
errors.append("required-platform manifest: expected schema v1 and platforms list")
@@ -149,8 +169,16 @@ def evaluate(manifest, coverage, results, mode_evidence=None, *, expected_revisi
if not isinstance(source.get("status"), str):
uncovered.append("interface report working-tree status missing or malformed")
reported_dirty = _relevant_status_lines(source.get("status"))
if reported_dirty:
uncovered.append("interface report recorded relevant dirty vehicle/runner source")
if current_snapshot is None:
from tools.ci.run_vehicle_tests import source_input_snapshot
current_snapshot = source_input_snapshot()
stable_snapshot = source_snapshot_matches(source, coverage.get("source_after"), current_snapshot)
if not stable_snapshot:
uncovered.append("interface report source inputs missing, changed during execution, or differ from current checkout")
if dirty_relevant:
uncovered.append("relevant vehicle/runner source differs from committed checkout")
if reported_dirty:
uncovered.append("interface report recorded relevant dirty vehicle/runner source")
revision = source.get("revision")
dependencies = source.get("dependencies")
opendbc = [d for d in dependencies if isinstance(d, dict) and d.get("path") == "opendbc_repo"] if isinstance(dependencies, list) else []
@@ -161,7 +189,8 @@ def evaluate(manifest, coverage, results, mode_evidence=None, *, expected_revisi
current_manifest = ROOT / "upstream-sync.json"
current_runner = ROOT / "tools/ci/run_vehicle_tests.py"
current_test_runner = ROOT / "tools/test_runner.py"
for field, path in (("manifest_sha256", current_manifest), ("suite_runner_sha256", current_runner), ("runner_sha256", current_test_runner)):
for field, path in (("manifest_sha256", current_manifest), ("suite_runner_sha256", current_runner), ("runner_sha256", current_test_runner),
("pytest_runner_sha256", ROOT / "tools/ci/vehicle_pytest.py")):
if source.get(field) != sha256(path):
uncovered.append(f"interface report {field} differs from current source")
pinned = json.loads(current_manifest.read_text()).get("dependencies", [])
@@ -185,7 +214,7 @@ def evaluate(manifest, coverage, results, mode_evidence=None, *, expected_revisi
uncovered.append("interface report pycapnp binary unavailable or changed")
if results.get("selected_test_ids") != selected:
errors.append("selected test order differs between coverage and results")
current_execution_compatible = not errors and not failures and not dirty_relevant and not reported_dirty
current_execution_compatible = not errors and not failures and stable_snapshot
current_execution_compatible &= not any(issue.startswith("interface report ") for issue in uncovered)
platform_rows = []
@@ -221,6 +250,22 @@ def evaluate(manifest, coverage, results, mode_evidence=None, *, expected_revisi
if status not in (None, "skipped"):
failures.append(f"upstream addition {p}: concrete interface test {status}")
if expected_registry is None:
from opendbc.car.values import PLATFORMS
expected_registry = set(PLATFORMS)
registry_gaps = sorted(set(expected_registry) ^ registered_set)
interface_gaps = [issue for issue in uncovered if issue.startswith("interface report ")]
if registry_gaps:
interface_gaps.append(f"current production registry differs from reported registry: {registry_gaps}")
for platform in sorted(registered_set):
test_id = mapped.get(platform)
if not isinstance(test_id, str) or by_test.get(test_id, {}).get("status") != "passed":
interface_gaps.append(f"{platform}: registered concrete interface test not passed")
interface_gate = {"scope": "current_registered_interface_regression",
"status": "error" if errors else "failed" if failures else "uncovered" if interface_gaps else "pass",
"errors": list(errors), "failures": list(failures), "uncovered": interface_gaps}
mode_summary = {"input": "absent", "required_scenarios": list(SCENARIOS), "configuration_count": 0, "recorded_trace_count": 0}
if mode_evidence is None:
uncovered.append("independent per-configuration mode evidence absent")
@@ -283,7 +328,7 @@ def evaluate(manifest, coverage, results, mode_evidence=None, *, expected_revisi
if row["current_interface"] == "passed":
row["current_interface"] = "historical_pass_only"
return {"schema_version": 1, "scope": "exact_source_platform_and_recorded_configuration_coverage",
"vehicle_qualification": "not_established", "status": status,
"vehicle_qualification": "not_established", "status": status, "interface_gate": interface_gate,
"source_revision": manifest.get("source_revision"), "interface_report_revision": revision,
"hashes": hashes or {}, "required_platform_count": len(required_set),
"upstream_addition_count": len(addition_set), "registered_platform_count": len(registered_set),
@@ -303,6 +348,8 @@ def main():
parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST)
parser.add_argument("--interface-coverage", type=Path, required=True)
parser.add_argument("--interface-results", type=Path, required=True)
parser.add_argument("--require", choices=("complete", "interfaces"), default="complete",
help="Exit gate; interfaces checks the current registry, not full Dom parity or mode readiness")
parser.add_argument("--mode-evidence", type=Path)
parser.add_argument("--expected-revision", help="40-character checkout revision; defaults to current HEAD")
parser.add_argument("--output", type=Path, required=True)
@@ -321,15 +368,17 @@ def main():
json.loads(args.interface_results.read_text()),
json.loads(args.mode_evidence.read_text()) if args.mode_evidence else None,
expected_revision=expected, hashes=hashes, dirty_relevant=relevant_dirty_paths())
report["required_gate"] = args.require
report["required_gate_status"] = report["interface_gate"]["status"] if args.require == "interfaces" else report["status"]
args.output.parent.mkdir(parents=True, exist_ok=True)
with args.output.open("x") as stream:
stream.write(json.dumps(report, indent=2, sort_keys=True) + "\n")
summary = (f"fleet coverage: {report['status']}; required={report['required_platform_count']}; " +
summary = (f"fleet coverage: {report['status']}; {args.require} gate={report['required_gate_status']}; required={report['required_platform_count']}; " +
f"registered={report['registered_platform_count']}; missing={report['reported_interface_counts'].get('missing_port', 0)}; " +
f"current passes={report['current_interface_counts'].get('passed', 0)}; " +
f"mode configurations={report['mode_evidence']['configuration_count']}; report={args.output}")
print(summary)
return 0 if report["status"] == "pass" else 1
return 0 if report["required_gate_status"] == "pass" else 1
if __name__ == "__main__":
+9 -3
View File
@@ -41,7 +41,7 @@ def load_fixtures(path=MANIFEST):
type(f['segment']) is not int or not 0 <= f['segment'] <= 10000 or
type(f['bytes']) is not int or not 0 < f['bytes'] <= MAX_FIXTURE_BYTES or
not re.fullmatch(r'[0-9a-f]{64}', f['sha256']) or
type(f['longitudinal']) is not bool or type(f['pcm_cruise']) is not bool or
type(f['longitudinal']) is not bool or type(f['recorded_longitudinal']) is not bool or type(f['pcm_cruise']) is not bool or
f['safety_model'] != 'tesla' or type(f['safety_param']) is not int or
not f['variants'] or len(set(f['variants'])) != len(f['variants']) or
not set(f['variants']) <= {'debug', 'release'}):
@@ -141,7 +141,11 @@ def make_cases(fixtures, paths, observed):
@classmethod
def get_testing_data(cls):
verify_fixture(cls.local_path, cls.fixture)
return cls.get_testing_data_from_logreader(LogReader(str(cls.local_path), only_union_types=True, sort_by_time=True))
firmware, messages, recorded_long = cls.get_testing_data_from_logreader(
LogReader(str(cls.local_path), only_union_types=True, sort_by_time=True))
if recorded_long != cls.fixture['recorded_longitudinal']:
raise AssertionError('Recorded longitudinal configuration changed')
return firmware, messages, cls.fixture['longitudinal']
@classmethod
def setUpClass(cls):
@@ -157,6 +161,7 @@ def make_cases(fixtures, paths, observed):
raise AssertionError('Recorded fixture no longer has its reviewed controller configuration')
observed.append({'fixture': cls.fixture['id'], 'platform': str(cp.carFingerprint),
'can_batches': len(cls.can_msgs), 'duration_seconds': (cls.can_msgs[-1][0] - cls.can_msgs[0][0]) / 1e9,
'recorded_longitudinal': cls.fixture['recorded_longitudinal'],
'longitudinal': cp.openpilotLongitudinalControl, 'pcm_cruise': cp.pcmCruise,
'safety_model': str(cp.safetyConfigs[0].safetyModel), 'safety_param': cp.safetyConfigs[0].safetyParam})
@@ -186,7 +191,8 @@ def run(variant, cache, output, download=False):
started = time.monotonic()
tests, records, errors, observed = [], [], [], []
plan = {'schema_version': 1, 'suite': 'recorded-vehicles', 'variant': variant,
'scope': 'Recorded CAN parser/native RX agreement; synthetic TX and fuzzy checks use the recorded fingerprint.',
'scope': ('Recorded CAN parser/native RX agreement in explicit stock/alpha configurations; ' +
'synthetic TX and fuzzy checks use the recorded fingerprint.'),
'uncovered': ['user weekend drive', 'physical CAN/harness/device behavior', 'road and whole-fleet qualification',
'recordings with incompatible historical schemas'],
'runner_sha256': vehicle.sha256(__file__), 'manifest_sha256': vehicle.sha256(MANIFEST),
+69 -9
View File
@@ -19,9 +19,43 @@ ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from tools import test_runner
from tools.ci import vehicle_pytest
RELEASE_TARGETS = (
"opendbc_repo/opendbc/safety/tests/test_tesla_preap_stock.py",
"opendbc_repo/opendbc/car/tesla/tests/test_preap_startup.py",
"opendbc_repo/opendbc/car/tesla/tests/test_preap_stock.py",
"openpilot/starpilot/car/tesla/tests/test_preap_intent.py",
"openpilot/starpilot/car/tesla/tests/test_stock_events.py",
"opendbc_repo/opendbc/car/ford/tests/test_aol_startup.py",
"openpilot/starpilot/tests/test_ford_aol_intent.py",
"opendbc_repo/opendbc/safety/tests/test_ford_aol.py",
"opendbc_repo/opendbc/safety/tests/test_ford_mach_e_extended.py",
"opendbc_repo/opendbc/car/ford/tests/test_added_firmware_identity.py",
"opendbc_repo/opendbc/car/ford/tests/test_required_source_health.py",
"opendbc_repo/opendbc/car/ford/tests/test_blend_settings.py",
"opendbc_repo/opendbc/car/ford/tests/test_blend_settings_startup.py",
"opendbc_repo/opendbc/car/ford/tests/test_production_extensions.py",
"opendbc_repo/opendbc/car/ford/tests/test_generic_canfd.py",
"opendbc_repo/opendbc/safety/tests/test_ford_generic_canfd_extended.py",
"opendbc_repo/opendbc/car/hyundai/tests/test_blended_stock_aol.py",
"openpilot/starpilot/aol/tests/test_nexo_stock_aol.py",
"opendbc_repo/opendbc/safety/tests/test_nexo_stock_aol.py",
"opendbc_repo/opendbc/safety/tests/test_hyundai_stock_angle_aol.py",
"opendbc_repo/opendbc/car/hyundai/tests/test_stock_angle_aol.py",
"opendbc_repo/opendbc/car/hyundai/tests/test_g90_startup.py",
"opendbc_repo/opendbc/safety/tests/test_hyundai_classic_long_aol.py",
"openpilot/starpilot/aol/tests/test_classic_long_profiles.py",
"opendbc_repo/opendbc/safety/tests/test_hyundai_shared_canfd_stock_aol.py",
"opendbc_repo/opendbc/safety/tests/test_hyundai_hda2_long_aol.py",
"opendbc_repo/opendbc/safety/tests/test_prepared_torque_ev_native.py",
"opendbc_repo/opendbc/car/hyundai/tests/test_shared_canfd_stock_aol.py",
"opendbc_repo/opendbc/safety/tests/test_hyundai_classic_scc_aol.py",
"openpilot/starpilot/aol/tests/test_classic_scc_profiles.py",
"opendbc_repo/opendbc/safety/tests/test_hyundai_blended.py",
"opendbc_repo/opendbc/safety/tests/test_hyundai_blended_alpha.py::TestHyundaiBlendedAlphaAol",
"opendbc_repo/opendbc/safety/tests/test_hyundai_blended_alpha.py::TestHyundaiBlendedHda2Integrity",
"opendbc_repo/opendbc/safety/tests/test_hyundai_ev9_long.py::TestEv9LongReleaseDenied",
"opendbc_repo/opendbc/car/hyundai/tests/test_palisade_2023.py",
"openpilot/starpilot/longitudinal/tests/test_output_max.py",
"openpilot/starpilot/ui/tests/test_output_max_feature.py",
@@ -40,7 +74,6 @@ RELEASE_TARGETS = (
"opendbc_repo/opendbc/safety/tests/test_tesla.py::TestTeslaStockSafety",
"opendbc_repo/opendbc/safety/tests/test_tesla_hw1.py",
"opendbc_repo/opendbc/safety/tests/test_volvo_c1.py",
"opendbc_repo/opendbc/safety/tests/test_tesla.py::TestTeslaFSD14StockSafety",
"opendbc_repo/opendbc/safety/tests/test_mazda.py::TestMazdaSafety",
"opendbc_repo/opendbc/safety/tests/test_toyota.py::TestToyotaStockLongitudinalTorque",
"opendbc_repo/opendbc/safety/tests/test_toyota_auto_hold.py",
@@ -48,6 +81,10 @@ RELEASE_TARGETS = (
"opendbc_repo/opendbc/safety/tests/test_gm.py::TestGmCameraEVSafety",
"opendbc_repo/opendbc/safety/tests/test_gm_bolt_pedal.py",
"opendbc_repo/opendbc/safety/tests/test_gm_bolt_cc.py",
"opendbc_repo/opendbc/car/gm/tests/test_bolt_cc_low.py",
"openpilot/starpilot/tests/test_bolt_cc_controls_low.py",
"openpilot/starpilot/tests/test_bolt_cc_controls_manual.py",
"openpilot/starpilot/car/gm/tests/test_gear_events.py",
"openpilot/starpilot/tests/test_bolt_disable_active_pedal.py::TestBoltDisableActivePedal",
"opendbc_repo/opendbc/car/gm/tests/test_bolt_acc_cc.py",
"opendbc_repo/opendbc/car/gm/tests/test_bolt_factory_acc.py",
@@ -59,6 +96,8 @@ RELEASE_TARGETS = (
"opendbc_repo/opendbc/safety/tests/test_gm_ascm_intercept.py",
"opendbc_repo/opendbc/safety/tests/test_gm_sdgm_stock.py",
"opendbc_repo/opendbc/safety/tests/test_gm_cc_gateway_stock.py",
"opendbc_repo/opendbc/safety/tests/test_gm_stock_cancel_delay.py",
"openpilot/starpilot/tests/test_gm_stock_caller.py",
"opendbc_repo/opendbc/safety/tests/test_gm_camera_stock_four.py",
"opendbc_repo/opendbc/safety/tests/test_gm_volt_gateway_mapping.py",
"opendbc_repo/opendbc/safety/tests/test_gm_volt_alternate_brake.py",
@@ -66,6 +105,7 @@ RELEASE_TARGETS = (
"opendbc_repo/opendbc/safety/tests/test_gm_ordinary_ascm.py",
"opendbc_repo/opendbc/safety/tests/test_gm_ordinary_sdgm.py",
"opendbc_repo/opendbc/safety/tests/test_gm_ordinary_cc.py",
"opendbc_repo/opendbc/safety/tests/test_gm_hybrid_cc.py",
"opendbc_repo/opendbc/car/gm/tests/test_ordinary_cc.py",
"opendbc_repo/opendbc/safety/tests/test_gm_ordinary_camera.py",
"opendbc_repo/opendbc/car/gm/tests/test_ordinary_camera.py",
@@ -87,9 +127,16 @@ RELEASE_TARGETS = (
"opendbc_repo/opendbc/safety/tests/test_hyundai_ccnc_angle_two.py",
"opendbc_repo/opendbc/safety/tests/test_hyundai_ioniq6_long.py",
"opendbc_repo/opendbc/safety/tests/test_hyundai_ray_pedal.py",
"opendbc_repo/opendbc/safety/tests/test_honda_mvl_stock.py",
"opendbc_repo/opendbc/safety/tests/test_honda_stock_aol.py",
"opendbc_repo/opendbc/safety/tests/test_mazda_stock_aol.py",
"opendbc_repo/opendbc/car/mazda/tests/test_stock_aol_pause.py",
"opendbc_repo/opendbc/safety/tests/test_honda_rdx_gas_cap.py",
"opendbc_repo/opendbc/safety/tests/test_honda_aol_source.py",
"opendbc_repo/opendbc/safety/tests/test_honda_aol_family.py",
"opendbc_repo/opendbc/safety/tests/test_aol_defensive_config.py",
"opendbc_repo/opendbc/safety/tests/test_honda_nidec_six.py",
"opendbc_repo/opendbc/safety/tests/test_honda_interceptor.py",
"opendbc_repo/opendbc/safety/tests/test_honda_bosch_radarless_three.py",
"opendbc_repo/opendbc/safety/tests/test_subaru_gen2_angle_pair.py",
"opendbc_repo/opendbc/safety/tests/test_subaru_ascent_angle.py",
@@ -159,12 +206,14 @@ def audit_interface_modules(targets, test_ids):
for method in node.body:
if isinstance(method, (ast.FunctionDef, ast.AsyncFunctionDef)) and method.name.startswith("test_"):
expected = f"{module}.{node.name}.{method.name}"
if not any(test_id == expected or test_id.startswith(expected + "_") for test_id in selected):
if not any(test_id == expected or test_id.startswith((expected + "_", expected + "[")) for test_id in selected):
missing_methods.append(f"{node.name}.{method.name}")
if not selected:
errors.append(f"Interface test module collected zero tests: {target}")
if functions:
errors.append(f"Unsupported module-level tests require an explicit runner: {target}: {functions}")
for function in functions:
expected = f"{module}.{function}"
if not any(test_id == expected or test_id.startswith(expected + "[") for test_id in selected):
errors.append(f"Declared module-level test was not collected: {target}: {function}")
if missing_methods:
errors.append(f"Declared interface tests were not collected: {target}: {missing_methods}")
modules.append({"path": target, "source_sha256": sha256(path), "collected_count": len(selected),
@@ -204,7 +253,8 @@ def source_provenance():
return {"revision": git("rev-parse", "HEAD"), "status": git("status", "--porcelain"),
"inputs": source_input_snapshot(),
"manifest_sha256": sha256(manifest), "dependencies": json.loads(manifest.read_text())["dependencies"],
"runner_sha256": sha256(ROOT / "tools/test_runner.py"), "suite_runner_sha256": sha256(__file__)}
"runner_sha256": sha256(ROOT / "tools/test_runner.py"), "suite_runner_sha256": sha256(__file__),
"pytest_runner_sha256": sha256(ROOT / "tools/ci/vehicle_pytest.py")}
def native_provenance():
@@ -255,6 +305,7 @@ def run(suite, output, collect_only=False):
output.mkdir(parents=True, exist_ok=True)
started = time.monotonic()
tests, errors = [], []
pytest_nodeids = []
records, platform_tests, gate_errors = [], {}, []
phase = "initialization"
plan = {"schema_version": 1, "suite": suite, "status": "initializing", "workers": 1,
@@ -274,8 +325,14 @@ def run(suite, output, collect_only=False):
if suite != "interfaces" and not collect_only:
plan["native"]["safety"] = build_safety_library(suite == "safety-release", output)
phase = "collection"
tests, errors = test_runner.collect(plan["targets"], None)
ids = [test.id() for test in tests]
unittest_targets, pytest_targets = vehicle_pytest.split_targets(ROOT, plan["targets"])
plan["runners"] = {"unittest": unittest_targets, "pytest": pytest_targets}
tests, errors = test_runner.collect(unittest_targets, None) if unittest_targets else ([], [])
pytest_nodeids, pytest_errors = vehicle_pytest.collect(pytest_targets)
errors.extend(pytest_errors)
ids = [test.id() for test in tests] + [vehicle_pytest.test_id(nodeid) for nodeid in pytest_nodeids]
if len(set(ids)) != len(ids):
errors.append("Vehicle runners selected duplicate test IDs")
if suite == "interfaces":
plan["module_collection"], module_errors = audit_interface_modules(plan["targets"], ids)
errors.extend(module_errors)
@@ -290,7 +347,10 @@ def run(suite, output, collect_only=False):
return 0
if not errors:
phase = "execution"
records = test_runner.run_batch(ids, capture_output=True)
records = test_runner.run_batch([test.id() for test in tests], capture_output=True) if tests else []
pytest_records, pytest_errors = vehicle_pytest.run(pytest_nodeids)
records.extend(pytest_records)
errors.extend(pytest_errors)
phase = "source verification"
plan["source_after"] = source_provenance()
if plan["source"].get("inputs") != plan["source_after"].get("inputs"):
@@ -298,7 +358,7 @@ def run(suite, output, collect_only=False):
except Exception:
errors.append(f"{phase} error:\n{traceback.format_exc()}")
plan["error_phase"] = phase
ids = [test.id() for test in tests]
ids = [test.id() for test in tests] + [vehicle_pytest.test_id(nodeid) for nodeid in pytest_nodeids]
records = test_runner.account_for_tests(ids, records, tests)
if not errors and suite == "interfaces":
gate_errors.extend(interface_execution_errors(platform_tests, records))
+67
View File
@@ -0,0 +1,67 @@
import copy
import subprocess
from pathlib import Path
from tempfile import TemporaryDirectory
import unittest
from unittest.mock import patch
from tools.ci import run_vehicle_tests
from tools.ci.fleet_coverage import source_snapshot_matches
class FleetSnapshotTest(unittest.TestCase):
def setUp(self):
directory = TemporaryDirectory()
self.addCleanup(directory.cleanup)
self.root = Path(directory.name)
self.git("init", "-q")
self.git("config", "user.name", "test")
self.git("config", "user.email", "test@example.invalid")
self.source = self.root / "opendbc_repo/car.py"
self.source.parent.mkdir()
self.source.write_text("value = 1\n")
self.binary = self.root / "opendbc_repo/parser.so"
self.binary.write_bytes(b"device-native")
self.git("add", ".")
self.git("commit", "-qm", "baseline")
self.source.write_text("value = 2\n")
self.binary.write_bytes(b"host-native")
self.before = self.snapshot()
self.report = {"revision": self.before["revision"], "inputs": self.before}
def git(self, *args):
return subprocess.check_output(["git", *args], cwd=self.root)
def snapshot(self):
with patch.object(run_vehicle_tests, "ROOT", self.root):
return run_vehicle_tests.source_input_snapshot()
def test_exact_dirty_source_and_host_native_snapshot_is_admitted(self):
self.assertTrue(source_snapshot_matches(self.report, copy.deepcopy(self.report), self.snapshot()))
def test_source_binary_and_new_untracked_mutations_are_rejected(self):
for path, value in ((self.source, b"value = 3\n"), (self.binary, b"different-native"),
(self.root / "opendbc_repo/new.py", b"new source")):
with self.subTest(path=path.name):
prior = path.read_bytes() if path.exists() else None
path.write_bytes(value)
self.assertFalse(source_snapshot_matches(self.report, self.report, self.snapshot()))
if prior is None:
path.unlink()
else:
path.write_bytes(prior)
def test_changed_head_is_rejected(self):
self.git("add", ".")
self.git("commit", "-qm", "new source")
self.assertFalse(source_snapshot_matches(self.report, self.report, self.snapshot()))
def test_missing_malformed_and_unstable_provenance_are_rejected(self):
self.assertFalse(source_snapshot_matches({}, self.report, self.before))
self.assertFalse(source_snapshot_matches(self.report, None, self.before))
changed = copy.deepcopy(self.report)
changed["inputs"]["tracked_patch_sha256"] = "0" * 64
self.assertFalse(source_snapshot_matches(self.report, changed, self.before))
malformed = copy.deepcopy(self.report)
malformed["inputs"]["paths"] = []
self.assertFalse(source_snapshot_matches(malformed, malformed, malformed["inputs"]))