diff --git a/opendbc_repo/opendbc/car/tests/routes.py b/opendbc_repo/opendbc/car/tests/routes.py index 80aa1a78a9..a1b2a9a5bc 100644 --- a/opendbc_repo/opendbc/car/tests/routes.py +++ b/opendbc_repo/opendbc/car/tests/routes.py @@ -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, diff --git a/opendbc_repo/opendbc/car/tests/test_routes.py b/opendbc_repo/opendbc/car/tests/test_routes.py index 331675dc36..0d0cb17071 100644 --- a/opendbc_repo/opendbc/car/tests/test_routes.py +++ b/opendbc_repo/opendbc/car/tests/test_routes.py @@ -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()) diff --git a/opendbc_repo/opendbc/car/values.py b/opendbc_repo/opendbc/car/values.py index 88cf32876b..db27ea878b 100644 --- a/opendbc_repo/opendbc/car/values.py +++ b/opendbc_repo/opendbc/car/values.py @@ -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} diff --git a/opendbc_repo/opendbc/car/volvo/__init__.py b/opendbc_repo/opendbc/car/volvo/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/opendbc_repo/opendbc/car/volvo/carcontroller.py b/opendbc_repo/opendbc/car/volvo/carcontroller.py new file mode 100644 index 0000000000..b3fa3e9df6 --- /dev/null +++ b/opendbc_repo/opendbc/car/volvo/carcontroller.py @@ -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 diff --git a/opendbc_repo/opendbc/car/volvo/carstate.py b/opendbc_repo/opendbc/car/volvo/carstate.py new file mode 100644 index 0000000000..ea3cf90435 --- /dev/null +++ b/opendbc_repo/opendbc/car/volvo/carstate.py @@ -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, + ), + } diff --git a/opendbc_repo/opendbc/car/volvo/fingerprints.py b/opendbc_repo/opendbc/car/volvo/fingerprints.py new file mode 100644 index 0000000000..1431dab012 --- /dev/null +++ b/opendbc_repo/opendbc/car/volvo/fingerprints.py @@ -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 = {} diff --git a/opendbc_repo/opendbc/car/volvo/interface.py b/opendbc_repo/opendbc/car/volvo/interface.py new file mode 100644 index 0000000000..2ee749ad9a --- /dev/null +++ b/opendbc_repo/opendbc/car/volvo/interface.py @@ -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 diff --git a/opendbc_repo/opendbc/car/volvo/tests/test_c1.py b/opendbc_repo/opendbc/car/volvo/tests/test_c1.py new file mode 100644 index 0000000000..e2eb6b1ff0 --- /dev/null +++ b/opendbc_repo/opendbc/car/volvo/tests/test_c1.py @@ -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) diff --git a/opendbc_repo/opendbc/car/volvo/values.py b/opendbc_repo/opendbc/car/volvo/values.py new file mode 100644 index 0000000000..fc25b4011e --- /dev/null +++ b/opendbc_repo/opendbc/car/volvo/values.py @@ -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() diff --git a/opendbc_repo/opendbc/car/volvo/volvocan.py b/opendbc_repo/opendbc/car/volvo/volvocan.py new file mode 100644 index 0000000000..e04e8c9b40 --- /dev/null +++ b/opendbc_repo/opendbc/car/volvo/volvocan.py @@ -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}) diff --git a/opendbc_repo/opendbc/safety/modes/volvo_c1.h b/opendbc_repo/opendbc/safety/modes/volvo_c1.h new file mode 100644 index 0000000000..3503695d2e --- /dev/null +++ b/opendbc_repo/opendbc/safety/modes/volvo_c1.h @@ -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}; diff --git a/opendbc_repo/opendbc/safety/safety.h b/opendbc_repo/opendbc/safety/safety.h index b4390c07c9..7397305bb8 100644 --- a/opendbc_repo/opendbc/safety/safety.h +++ b/opendbc_repo/opendbc/safety/safety.h @@ -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; diff --git a/opendbc_repo/opendbc/safety/tests/test_volvo_c1.py b/opendbc_repo/opendbc/safety/tests/test_volvo_c1.py new file mode 100644 index 0000000000..833f1406e1 --- /dev/null +++ b/opendbc_repo/opendbc/safety/tests/test_volvo_c1.py @@ -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)))) diff --git a/openpilot/starpilot/tests/test_vehicle_selection.py b/openpilot/starpilot/tests/test_vehicle_selection.py index 3c03200dec..b04de1b195 100644 --- a/openpilot/starpilot/tests/test_vehicle_selection.py +++ b/openpilot/starpilot/tests/test_vehicle_selection.py @@ -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'): diff --git a/openpilot/starpilot/vehicle_selection.py b/openpilot/starpilot/vehicle_selection.py index 9ddb46c359..444df6bbcd 100644 --- a/openpilot/starpilot/vehicle_selection.py +++ b/openpilot/starpilot/vehicle_selection.py @@ -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-') diff --git a/tools/ci/fleet_coverage.py b/tools/ci/fleet_coverage.py index c6330f0a40..da9f2c0f50 100644 --- a/tools/ci/fleet_coverage.py +++ b/tools/ci/fleet_coverage.py @@ -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__": diff --git a/tools/ci/run_recorded_vehicle_tests.py b/tools/ci/run_recorded_vehicle_tests.py index 6c4b5e3b28..289b41df2b 100644 --- a/tools/ci/run_recorded_vehicle_tests.py +++ b/tools/ci/run_recorded_vehicle_tests.py @@ -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), diff --git a/tools/ci/run_vehicle_tests.py b/tools/ci/run_vehicle_tests.py index f90aaf3aaf..b56aad7952 100644 --- a/tools/ci/run_vehicle_tests.py +++ b/tools/ci/run_vehicle_tests.py @@ -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)) diff --git a/tools/ci/tests/test_fleet_snapshot.py b/tools/ci/tests/test_fleet_snapshot.py new file mode 100644 index 0000000000..2711da4999 --- /dev/null +++ b/tools/ci/tests/test_fleet_snapshot.py @@ -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"]))