mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-12 15:33:45 +08:00
Volvo
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:
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
@@ -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};
|
||||
@@ -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'):
|
||||
|
||||
@@ -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
@@ -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__":
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"]))
|
||||
Reference in New Issue
Block a user