openpilot is an open source driver assistance system. openpilot performs the functions of Automated Lane Centering and Adaptive Cruise Control for over 200 supported car makes and models.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

328 lines
14 KiB

import pytest
import random
import time
from collections import defaultdict
from opendbc.car.can_definitions import CanData
from opendbc.car.car_helpers import interfaces
from opendbc.car.structs import CarParams
from opendbc.car.fingerprints import FW_VERSIONS
from opendbc.car.fw_versions import FW_QUERY_CONFIGS, FUZZY_EXCLUDE_ECUS, VERSIONS, build_fw_dict, \
match_fw_to_car, get_brand_ecu_matches, get_fw_versions, get_present_ecus
from opendbc.car.vin import get_vin
CarFw = CarParams.CarFw
Ecu = CarParams.Ecu
ECU_NAME = {v: k for k, v in Ecu.schema.enumerants.items()}
class TestFwFingerprint:
def assertFingerprints(self, candidates, expected):
candidates = list(candidates)
assert len(candidates) == 1, f"got more than one candidate: {candidates}"
assert candidates[0] == expected
@pytest.mark.parametrize("brand, car_model, ecus, test_non_essential",
[(b, c, e[c], n) for b, e in VERSIONS.items() for c in e for n in (True, False)])
def test_exact_match(self, brand, car_model, ecus, test_non_essential):
config = FW_QUERY_CONFIGS[brand]
CP = CarParams()
for _ in range(20):
fw = []
for ecu, fw_versions in ecus.items():
# Assume non-essential ECUs apply to all cars, so we catch cases where Car A with
# missing ECUs won't match to Car B where only Car B has labeled non-essential ECUs
if ecu[0] in config.non_essential_ecus and test_non_essential:
continue
ecu_name, addr, sub_addr = ecu
fw.append(CarFw(ecu=ecu_name, fwVersion=random.choice(fw_versions), brand=brand,
address=addr, subAddress=0 if sub_addr is None else sub_addr))
CP.carFw = fw
_, matches = match_fw_to_car(CP.carFw, CP.carVin, allow_fuzzy=False)
if not test_non_essential:
self.assertFingerprints(matches, car_model)
else:
# if we're removing ECUs we expect some match loss, but it shouldn't mismatch
if len(matches) != 0:
self.assertFingerprints(matches, car_model)
@pytest.mark.parametrize("brand, car_model, ecus", [(b, c, e[c]) for b, e in VERSIONS.items() for c in e])
def test_custom_fuzzy_match(self, brand, car_model, ecus):
# Assert brand-specific fuzzy fingerprinting function doesn't disagree with standard fuzzy function
config = FW_QUERY_CONFIGS[brand]
if config.match_fw_to_car_fuzzy is None:
pytest.skip("Brand does not implement custom fuzzy fingerprinting function")
CP = CarParams()
for _ in range(5):
fw = []
for ecu, fw_versions in ecus.items():
ecu_name, addr, sub_addr = ecu
fw.append(CarFw(ecu=ecu_name, fwVersion=random.choice(fw_versions), brand=brand,
address=addr, subAddress=0 if sub_addr is None else sub_addr))
CP.carFw = fw
_, matches = match_fw_to_car(CP.carFw, CP.carVin, allow_exact=False, log=False)
brand_matches = config.match_fw_to_car_fuzzy(build_fw_dict(CP.carFw), CP.carVin, VERSIONS[brand])
# If both have matches, they must agree
if len(matches) == 1 and len(brand_matches) == 1:
assert matches == brand_matches
@pytest.mark.parametrize("brand, car_model, ecus", [(b, c, e[c]) for b, e in VERSIONS.items() for c in e])
def test_fuzzy_match_ecu_count(self, brand, car_model, ecus):
# Asserts that fuzzy matching does not count matching FW, but ECU address keys
valid_ecus = [e for e in ecus if e[0] not in FUZZY_EXCLUDE_ECUS]
if not len(valid_ecus):
pytest.skip("Car model has no compatible ECUs for fuzzy matching")
fw = []
for ecu in valid_ecus:
ecu_name, addr, sub_addr = ecu
for _ in range(5):
# Add multiple FW versions to simulate ECU returning to multiple queries in a brand
fw.append(CarFw(ecu=ecu_name, fwVersion=random.choice(ecus[ecu]), brand=brand,
address=addr, subAddress=0 if sub_addr is None else sub_addr))
CP = CarParams(carFw=fw)
_, matches = match_fw_to_car(CP.carFw, CP.carVin, allow_exact=False, log=False)
# Assert no match if there are not enough unique ECUs
unique_ecus = {(f.address, f.subAddress) for f in fw}
if len(unique_ecus) < 2:
assert len(matches) == 0, car_model
# There won't always be a match due to shared FW, but if there is it should be correct
elif len(matches):
self.assertFingerprints(matches, car_model)
def test_fw_version_lists(self, subtests):
for car_model, ecus in FW_VERSIONS.items():
with subtests.test(car_model=car_model.value):
for ecu, ecu_fw in ecus.items():
with subtests.test(ecu):
duplicates = {fw for fw in ecu_fw if ecu_fw.count(fw) > 1}
assert not len(duplicates), f'{car_model}: Duplicate FW versions: Ecu.{ecu[0]}, {duplicates}'
assert len(ecu_fw) > 0, f'{car_model}: No FW versions: Ecu.{ecu[0]}'
def test_all_addrs_map_to_one_ecu(self):
for brand, cars in VERSIONS.items():
addr_to_ecu = defaultdict(set)
for ecus in cars.values():
for ecu_type, addr, sub_addr in ecus.keys():
addr_to_ecu[(addr, sub_addr)].add(ecu_type)
ecus_for_addr = addr_to_ecu[(addr, sub_addr)]
ecu_strings = ", ".join([f'Ecu.{ecu}' for ecu in ecus_for_addr])
assert len(ecus_for_addr) <= 1, f"{brand} has multiple ECUs that map to one address: {ecu_strings} -> ({hex(addr)}, {sub_addr})"
def test_data_collection_ecus(self, subtests):
# Asserts no extra ECUs are in the fingerprinting database
for brand, config in FW_QUERY_CONFIGS.items():
for car_model, ecus in VERSIONS[brand].items():
bad_ecus = set(ecus).intersection(config.extra_ecus)
with subtests.test(car_model=car_model.value):
assert not len(bad_ecus), f'{car_model}: Fingerprints contain ECUs added for data collection: {bad_ecus}'
def test_blacklisted_ecus(self, subtests):
blacklisted_addrs = (0x7c4, 0x7d0) # includes A/C ecu and an unknown ecu
for car_model, ecus in FW_VERSIONS.items():
with subtests.test(car_model=car_model.value):
CP = interfaces[car_model].get_non_essential_params(car_model)
if CP.brand == 'subaru':
for ecu in ecus.keys():
assert ecu[1] not in blacklisted_addrs, f'{car_model}: Blacklisted ecu: (Ecu.{ecu[0]}, {hex(ecu[1])})'
elif CP.brand == "chrysler":
# Some HD trucks have a combined TCM and ECM
if CP.carFingerprint.startswith("RAM_HD"):
for ecu in ecus.keys():
assert ecu[0] != Ecu.transmission, f"{car_model}: Blacklisted ecu: (Ecu.{ecu[0]}, {hex(ecu[1])})"
def test_missing_versions_and_configs(self, subtests):
brand_versions = set(VERSIONS.keys())
brand_configs = set(FW_QUERY_CONFIGS.keys())
if len(brand_configs - brand_versions):
with subtests.test():
pytest.fail(f"Brands do not implement FW_VERSIONS: {brand_configs - brand_versions}")
if len(brand_versions - brand_configs):
with subtests.test():
pytest.fail(f"Brands do not implement FW_QUERY_CONFIG: {brand_versions - brand_configs}")
# Ensure each brand has at least 1 ECU to query, and extra ECU retrieval
for brand, config in FW_QUERY_CONFIGS.items():
assert len(config.get_all_ecus({}, include_extra_ecus=False)) == 0
assert config.get_all_ecus({}) == set(config.extra_ecus)
if len(VERSIONS[brand]) > 0:
assert len(config.get_all_ecus(VERSIONS[brand])) > 0
def test_fw_request_ecu_whitelist(self, subtests):
for brand, config in FW_QUERY_CONFIGS.items():
with subtests.test(brand=brand):
whitelisted_ecus = {ecu for r in config.requests for ecu in r.whitelist_ecus}
brand_ecus = {fw[0] for car_fw in VERSIONS[brand].values() for fw in car_fw}
brand_ecus |= {ecu[0] for ecu in config.extra_ecus}
# each ecu in brand's fw versions + extra ecus needs to be whitelisted at least once
ecus_not_whitelisted = brand_ecus - whitelisted_ecus
ecu_strings = ", ".join([f'Ecu.{ecu}' for ecu in ecus_not_whitelisted])
assert not (len(whitelisted_ecus) and len(ecus_not_whitelisted)), \
f'{brand.title()}: ECUs not in any FW query whitelists: {ecu_strings}'
def test_request_ecus_in_versions(self):
# All ECUs in requests should be in the brand's FW versions
for brand, config in FW_QUERY_CONFIGS.items():
request_ecus = {ecu for r in config.requests for ecu in r.whitelist_ecus} - {ecu[0] for ecu in config.extra_ecus}
print(brand, request_ecus)
version_ecus = config.get_all_ecus(VERSIONS[brand], include_extra_ecus=False)
for request_ecu in request_ecus:
assert request_ecu in {e for e, _, _ in version_ecus}, f"Ecu.{ECU_NAME[request_ecu]} not in {brand} FW versions"
def test_brand_ecu_matches(self):
brand_matches = get_brand_ecu_matches(set())
assert len(brand_matches) > 0
assert all(len(e) and not any(e) for e in brand_matches.values())
# we ignore bus
brand_matches = get_brand_ecu_matches({(0x758, 0xf, 99)})
assert True in brand_matches['toyota']
assert not any(any(e) for b, e in brand_matches.items() if b != 'toyota')
class TestFwFingerprintTiming:
N: int = 5
TOL: float = 0.05
# for patched functions
current_obd_multiplexing: bool
total_time: float
@staticmethod
def fake_can_send(msgs):
pass
@staticmethod
def fake_can_recv(wait_for_one: bool = False) -> list[list[CanData]]:
return ([[CanData(random.randint(0x600, 0x800), b'\x00' * 8, 0)]]
if random.uniform(0, 1) > 0.5 else [])
def fake_set_obd_multiplexing(self, obd_multiplexing):
"""The 10Hz blocking params loop adds on average 50ms to the query time for each OBD multiplexing change"""
if obd_multiplexing != self.current_obd_multiplexing:
self.current_obd_multiplexing = obd_multiplexing
self.total_time += 0.1 / 2
def fake_get_data(self, timeout):
self.total_time += timeout
return {}
def _benchmark_brand(self, brand, num_pandas, mocker):
self.total_time = 0
mocker.patch("opendbc.car.isotp_parallel_query.IsoTpParallelQuery.get_data", self.fake_get_data)
for _ in range(self.N):
# Treat each brand as the most likely (aka, the first) brand with OBD multiplexing initially on
self.current_obd_multiplexing = True
t = time.perf_counter()
get_fw_versions(self.fake_can_recv, self.fake_can_send, self.fake_set_obd_multiplexing, brand, num_pandas=num_pandas)
self.total_time += time.perf_counter() - t
return self.total_time / self.N
def _assert_timing(self, avg_time, ref_time):
assert avg_time < ref_time + self.TOL
assert avg_time > ref_time - self.TOL, "Performance seems to have improved, update test refs."
def test_startup_timing(self, subtests, mocker):
# Tests worse-case VIN query time and typical present ECU query time
vin_ref_times = {'worst': 1.6, 'best': 0.8} # best assumes we go through all queries to get a match
present_ecu_ref_time = 0.45
def fake_get_ecu_addrs(*_, timeout):
self.total_time += timeout
return set()
self.total_time = 0.0
mocker.patch("opendbc.car.fw_versions.get_ecu_addrs", fake_get_ecu_addrs)
for _ in range(self.N):
self.current_obd_multiplexing = True
get_present_ecus(self.fake_can_recv, self.fake_can_send, self.fake_set_obd_multiplexing, num_pandas=2)
self._assert_timing(self.total_time / self.N, present_ecu_ref_time)
print(f'get_present_ecus, query time={self.total_time / self.N} seconds')
for name, args in (('worst', {}), ('best', {'retry': 1})):
with subtests.test(name=name):
self.total_time = 0.0
mocker.patch("opendbc.car.isotp_parallel_query.IsoTpParallelQuery.get_data", self.fake_get_data)
for _ in range(self.N):
get_vin(self.fake_can_recv, self.fake_can_send, (0, 1), **args)
self._assert_timing(self.total_time / self.N, vin_ref_times[name])
print(f'get_vin {name} case, query time={self.total_time / self.N} seconds')
def test_fw_query_timing(self, subtests, mocker):
total_ref_time = {1: 7.3, 2: 7.9}
brand_ref_times = {
1: {
'gm': 1.0,
'body': 0.1,
'chrysler': 0.3,
'ford': 1.5,
'honda': 0.45,
'hyundai': 0.65,
'mazda': 0.1,
'nissan': 0.8,
'subaru': 0.65,
'tesla': 0.1,
'toyota': 0.7,
'volkswagen': 0.65,
'rivian': 0.3,
},
2: {
'ford': 1.6,
'hyundai': 1.15,
}
}
total_times = {1: 0.0, 2: 0.0}
for num_pandas in (1, 2):
for brand, config in FW_QUERY_CONFIGS.items():
with subtests.test(brand=brand, num_pandas=num_pandas):
avg_time = self._benchmark_brand(brand, num_pandas, mocker)
total_times[num_pandas] += avg_time
avg_time = round(avg_time, 2)
ref_time = brand_ref_times[num_pandas].get(brand)
if ref_time is None:
# ref time should be same as 1 panda if no aux queries
ref_time = brand_ref_times[num_pandas - 1][brand]
self._assert_timing(avg_time, ref_time)
print(f'{brand=}, {num_pandas=}, {len(config.requests)=}, avg FW query time={avg_time} seconds')
for num_pandas in (1, 2):
with subtests.test(brand='all_brands', num_pandas=num_pandas):
total_time = round(total_times[num_pandas], 2)
self._assert_timing(total_time, total_ref_time[num_pandas])
print(f'all brands, total FW query time={total_time} seconds')
def test_get_fw_versions(self, subtests, mocker):
# some coverage on IsoTpParallelQuery and panda UDS library
# TODO: replace this with full fingerprint simulation testing
# https://github.com/commaai/panda/pull/1329
def fake_carlog_exception(*args, **kwargs):
raise
t = 0
def fake_monotonic():
nonlocal t
t += 0.0001
return t
mocker.patch("opendbc.car.carlog.carlog.exception", fake_carlog_exception)
mocker.patch("time.monotonic", fake_monotonic)
for brand in FW_QUERY_CONFIGS.keys():
with subtests.test(brand=brand):
get_fw_versions(self.fake_can_recv, self.fake_can_send, lambda obd: None, brand)