diff --git a/cereal b/cereal index 957147cb84..b39c6fc26d 160000 --- a/cereal +++ b/cereal @@ -1 +1 @@ -Subproject commit 957147cb8428d984a776ca468f866160f3b71bbc +Subproject commit b39c6fc26d93ca776b27a2e4005b12ae85e7bacc diff --git a/selfdrive/car/car_helpers.py b/selfdrive/car/car_helpers.py index 2af6df269e..32f0f29f7f 100644 --- a/selfdrive/car/car_helpers.py +++ b/selfdrive/car/car_helpers.py @@ -13,7 +13,7 @@ from cereal import car EventName = car.CarEvent.EventName -def get_startup_event(car_recognized, controller_available): +def get_startup_event(car_recognized, controller_available, fuzzy_fingerprint): if comma_remote and tested_branch: event = EventName.startup else: @@ -23,6 +23,8 @@ def get_startup_event(car_recognized, controller_available): event = EventName.startupNoCar elif car_recognized and not controller_available: event = EventName.startupNoControl + elif car_recognized and fuzzy_fingerprint: + event = EventName.startupFuzzyFingerprint return event @@ -104,10 +106,10 @@ def fingerprint(logcan, sendcan): _, vin = get_vin(logcan, sendcan, bus) car_fw = get_fw_versions(logcan, sendcan, bus) - fw_candidates = match_fw_to_car(car_fw) + exact_fw_match, fw_candidates = match_fw_to_car(car_fw) else: vin = VIN_UNKNOWN - fw_candidates, car_fw = set(), [] + exact_fw_match, fw_candidates, car_fw = True, set(), [] cloudlog.warning("VIN %s", vin) Params().put("CarVin", vin) @@ -152,23 +154,25 @@ def fingerprint(logcan, sendcan): frame += 1 + exact_match = True source = car.CarParams.FingerprintSource.can # If FW query returns exactly 1 candidate, use it if len(fw_candidates) == 1: car_fingerprint = list(fw_candidates)[0] source = car.CarParams.FingerprintSource.fw + exact_match = exact_fw_match if fixed_fingerprint: car_fingerprint = fixed_fingerprint source = car.CarParams.FingerprintSource.fixed cloudlog.warning("fingerprinted %s", car_fingerprint) - return car_fingerprint, finger, vin, car_fw, source + return car_fingerprint, finger, vin, car_fw, source, exact_match def get_car(logcan, sendcan): - candidate, fingerprints, vin, car_fw, source = fingerprint(logcan, sendcan) + candidate, fingerprints, vin, car_fw, source, exact_match = fingerprint(logcan, sendcan) if candidate is None: cloudlog.warning("car doesn't match any fingerprints: %r", fingerprints) @@ -179,5 +183,6 @@ def get_car(logcan, sendcan): car_params.carVin = vin car_params.carFw = car_fw car_params.fingerprintSource = source + car_params.fuzzyFingerprint = not exact_match return CarInterface(car_params, CarController, CarState), car_params diff --git a/selfdrive/car/fw_versions.py b/selfdrive/car/fw_versions.py index 468b17ba85..4ca3f6e00a 100755 --- a/selfdrive/car/fw_versions.py +++ b/selfdrive/car/fw_versions.py @@ -2,6 +2,7 @@ import struct import traceback from typing import Any +from collections import defaultdict from tqdm import tqdm @@ -136,15 +137,67 @@ def chunks(l, n=128): yield l[i:i + n] -def match_fw_to_car(fw_versions): - candidates = FW_VERSIONS - invalid = [] - +def build_fw_dict(fw_versions): fw_versions_dict = {} for fw in fw_versions: addr = fw.address sub_addr = fw.subAddress if fw.subAddress != 0 else None fw_versions_dict[(addr, sub_addr)] = fw.fwVersion + return fw_versions_dict + + +def match_fw_to_car_fuzzy(fw_versions_dict, log=True, exclude=None): + """Do a fuzzy FW match. This function will return a match, and the number of firmware version + that were matched uniquely to that specific car. If multiple ECUs uniquely match to different cars + the match is rejected.""" + + # These ECUs are known to be shared between models (EPS only between hybrid/ICE version) + # Getting this exactly right isn't crucial, but excluding camera and radar makes it almost + # impossible to get 3 matching versions, even if two models with shared parts are released at the same + # time and only one is in our database. + exclude_types = [Ecu.fwdCamera, Ecu.fwdRadar, Ecu.eps] + + # Build lookup table from (addr, subaddr, fw) to list of candidate cars + all_fw_versions = defaultdict(list) + for candidate, fw_by_addr in FW_VERSIONS.items(): + if candidate == exclude: + continue + + for addr, fws in fw_by_addr.items(): + if addr[0] in exclude_types: + continue + for f in fws: + all_fw_versions[(addr[1], addr[2], f)].append(candidate) + + match_count = 0 + candidate = None + for addr, version in fw_versions_dict.items(): + # All cars that have this FW response on the specified address + candidates = all_fw_versions[(addr[0], addr[1], version)] + + if len(candidates) == 1: + match_count += 1 + if candidate is None: + candidate = candidates[0] + # We uniquely matched two different cars. No fuzzy match possible + elif candidate != candidates[0]: + return set() + + if match_count >= 2: + if log: + cloudlog.error(f"Fingerprinted {candidate} using fuzzy match. {match_count} matching ECUs") + return set([candidate]) + else: + return set() + + +def match_fw_to_car_exact(fw_versions_dict): + """Do an exact FW match. Returns all cars that match the given + FW versions for a list of "essential" ECUs. If an ECU is not considered + essential the FW version can be missing to get a fingerprint, but if it's present it + needs to match the database.""" + invalid = [] + candidates = FW_VERSIONS for candidate, fws in candidates.items(): for ecu, expected_versions in fws.items(): @@ -155,11 +208,11 @@ def match_fw_to_car(fw_versions): if ecu_type == Ecu.esp and candidate in [TOYOTA.RAV4, TOYOTA.COROLLA, TOYOTA.HIGHLANDER] and found_version is None: continue - # TODO: on some toyota, the engine can show on two different addresses + # On some Toyota models, the engine can show on two different addresses if ecu_type == Ecu.engine and candidate in [TOYOTA.COROLLA_TSS2, TOYOTA.CHR, TOYOTA.LEXUS_IS, TOYOTA.AVALON] and found_version is None: continue - # ignore non essential ecus + # Ignore non essential ecus if ecu_type not in ESSENTIAL_ECUS and found_version is None: continue @@ -170,6 +223,21 @@ def match_fw_to_car(fw_versions): return set(candidates.keys()) - set(invalid) +def match_fw_to_car(fw_versions, allow_fuzzy=True): + fw_versions_dict = build_fw_dict(fw_versions) + matches = match_fw_to_car_exact(fw_versions_dict) + + exact_match = True + if allow_fuzzy and len(matches) == 0: + matches = match_fw_to_car_fuzzy(fw_versions_dict) + + # Fuzzy match found + if len(matches) == 1: + exact_match = False + + return exact_match, matches + + def get_fw_versions(logcan, sendcan, bus, extra=None, timeout=0.1, debug=False, progress=False): ecu_types = {} @@ -264,7 +332,7 @@ if __name__ == "__main__": t = time.time() fw_vers = get_fw_versions(logcan, sendcan, 1, extra=extra, debug=args.debug, progress=True) - candidates = match_fw_to_car(fw_vers) + _, candidates = match_fw_to_car(fw_vers) print() print("Found FW versions") diff --git a/selfdrive/car/tests/test_fw_fingerprint.py b/selfdrive/car/tests/test_fw_fingerprint.py index 1f217eead3..fae14cc864 100755 --- a/selfdrive/car/tests/test_fw_fingerprint.py +++ b/selfdrive/car/tests/test_fw_fingerprint.py @@ -28,7 +28,8 @@ class TestFwFingerprint(unittest.TestCase): fw.append({"ecu": ecu_name, "fwVersion": random.choice(fw_versions), "address": addr, "subAddress": 0 if sub_addr is None else sub_addr}) CP.carFw = fw - self.assertFingerprints(match_fw_to_car(CP.carFw), car_model) + _, matches = match_fw_to_car(CP.carFw) + self.assertFingerprints(matches, car_model) def test_no_duplicate_fw_versions(self): passed = True diff --git a/selfdrive/controls/controlsd.py b/selfdrive/controls/controlsd.py index 8914062f9e..f56c42bf39 100755 --- a/selfdrive/controls/controlsd.py +++ b/selfdrive/controls/controlsd.py @@ -84,9 +84,12 @@ class Controls: sounds_available = HARDWARE.get_sound_card_online() car_recognized = self.CP.carName != 'mock' + fuzzy_fingerprint = self.CP.fuzzyFingerprint + # If stock camera is disconnected, we loaded car controls and it's not dashcam mode controller_available = self.CP.enableCamera and self.CI.CC is not None and not passive and not self.CP.dashcamOnly - community_feature_disallowed = self.CP.communityFeature and not community_feature_toggle + community_feature = self.CP.communityFeature or fuzzy_fingerprint + community_feature_disallowed = community_feature and (not community_feature_toggle) self.read_only = not car_recognized or not controller_available or \ self.CP.dashcamOnly or community_feature_disallowed if self.read_only: @@ -137,7 +140,7 @@ class Controls: self.sm['driverMonitoringState'].faceDetected = False self.sm['liveParameters'].valid = True - self.startup_event = get_startup_event(car_recognized, controller_available) + self.startup_event = get_startup_event(car_recognized, controller_available, fuzzy_fingerprint) if not sounds_available: self.events.add(EventName.soundsUnavailable, static=True) diff --git a/selfdrive/controls/lib/events.py b/selfdrive/controls/lib/events.py index 019016a8da..bb4ec5b78b 100644 --- a/selfdrive/controls/lib/events.py +++ b/selfdrive/controls/lib/events.py @@ -208,6 +208,13 @@ def wrong_car_mode_alert(CP: car.CarParams, sm: messaging.SubMaster, metric: boo text = "Main Switch Off" return NoEntryAlert(text, duration_hud_alert=0.) +def startup_fuzzy_fingerprint_alert(CP: car.CarParams, sm: messaging.SubMaster, metric: bool) -> Alert: + return Alert( + "WARNING: No Exact Match on Car Model", + f"Closest Match: {CP.carFingerprint.title()[:40]}", + AlertStatus.userPrompt, AlertSize.mid, + Priority.LOWER, VisualAlert.none, AudibleAlert.none, 0., 0., 15.) + EVENTS: Dict[int, Dict[str, Union[Alert, Callable[[Any, messaging.SubMaster, bool], Alert]]]] = { # ********** events with no alerts ********** @@ -253,6 +260,10 @@ EVENTS: Dict[int, Dict[str, Union[Alert, Callable[[Any, messaging.SubMaster, boo Priority.LOWER, VisualAlert.none, AudibleAlert.none, 0., 0., 15.), }, + EventName.startupFuzzyFingerprint: { + ET.PERMANENT: startup_fuzzy_fingerprint_alert, + }, + EventName.dashcamMode: { ET.PERMANENT: Alert( "Dashcam Mode", diff --git a/selfdrive/controls/tests/test_startup.py b/selfdrive/controls/tests/test_startup.py index f6ac98fbce..83fc643c9b 100755 --- a/selfdrive/controls/tests/test_startup.py +++ b/selfdrive/controls/tests/test_startup.py @@ -9,11 +9,23 @@ from common.params import Params from selfdrive.boardd.boardd_api_impl import can_list_to_can_capnp # pylint: disable=no-name-in-module,import-error from selfdrive.car.fingerprints import _FINGERPRINTS from selfdrive.car.hyundai.values import CAR as HYUNDAI +from selfdrive.car.toyota.values import CAR as TOYOTA from selfdrive.car.mazda.values import CAR as MAZDA from selfdrive.controls.lib.events import EVENT_NAME from selfdrive.test.helpers import with_processes EventName = car.CarEvent.EventName +Ecu = car.CarParams.Ecu + +COROLLA_TSS2_FW_VERSIONS = [ + (Ecu.engine, 0x700, None, b'\x01896630ZG5000\x00\x00\x00\x00'), + (Ecu.eps, 0x7a1, None, b'\x018965B1255000\x00\x00\x00\x00'), + (Ecu.esp, 0x7b0, None, b'\x01F152602280\x00\x00\x00\x00\x00\x00'), + (Ecu.fwdRadar, 0x750, 0xf, b'\x018821F3301100\x00\x00\x00\x00'), + (Ecu.fwdCamera, 0x750, 0x6d, b'\x028646F12010D0\x00\x00\x00\x008646G26011A0\x00\x00\x00\x00'), +] +COROLLA_TSS2_FW_VERSIONS_FUZZY = COROLLA_TSS2_FW_VERSIONS[:-1] + [(Ecu.fwdCamera, 0x750, 0x6d, b'xxxxxx')] + class TestStartup(unittest.TestCase): @@ -21,23 +33,30 @@ class TestStartup(unittest.TestCase): # TODO: test EventName.startup for release branches # officially supported car - (EventName.startupMaster, HYUNDAI.SONATA, False), - (EventName.startupMaster, HYUNDAI.SONATA, True), + (EventName.startupMaster, HYUNDAI.SONATA, False, None), + (EventName.startupMaster, HYUNDAI.SONATA, True, None), + + # offically supported car, FW query + (EventName.startupMaster, TOYOTA.COROLLA_TSS2, False, COROLLA_TSS2_FW_VERSIONS), # community supported car - (EventName.startupMaster, HYUNDAI.KIA_STINGER, True), - (EventName.communityFeatureDisallowed, HYUNDAI.KIA_STINGER, False), + (EventName.startupMaster, HYUNDAI.KIA_STINGER, True, None), + (EventName.communityFeatureDisallowed, HYUNDAI.KIA_STINGER, False, None), # dashcamOnly car - (EventName.startupNoControl, MAZDA.CX5, True), - (EventName.startupNoControl, MAZDA.CX5, False), + (EventName.startupNoControl, MAZDA.CX5, True, None), + (EventName.startupNoControl, MAZDA.CX5, False, None), # unrecognized car - (EventName.startupNoCar, None, True), - (EventName.startupNoCar, None, False), + (EventName.startupNoCar, None, True, None), + (EventName.startupNoCar, None, False, None), + + # fuzzy match + (EventName.startupFuzzyFingerprint, TOYOTA.COROLLA_TSS2, True, COROLLA_TSS2_FW_VERSIONS_FUZZY), + (EventName.communityFeatureDisallowed, TOYOTA.COROLLA_TSS2, False, COROLLA_TSS2_FW_VERSIONS_FUZZY), ]) @with_processes(['controlsd']) - def test_startup_alert(self, expected_event, car, toggle_enabled): + def test_startup_alert(self, expected_event, car_model, toggle_enabled, fw_versions): # TODO: this should be done without any real sockets controls_sock = messaging.sub_sock("controlsState") @@ -49,6 +68,24 @@ class TestStartup(unittest.TestCase): params.put_bool("OpenpilotEnabledToggle", True) params.put_bool("CommunityFeaturesToggle", toggle_enabled) + # Build capnn version of FW array + if fw_versions is not None: + car_fw = [] + cp = car.CarParams.new_message() + for ecu, addr, subaddress, version in fw_versions: + f = car.CarParams.CarFw.new_message() + f.ecu = ecu + f.address = addr + f.fwVersion = version + + if subaddress is not None: + f.subAddress = subaddress + + car_fw.append(f) + cp.carVin = "1" * 17 + cp.carFw = car_fw + params.put("CarParamsCache", cp.to_bytes()) + time.sleep(2) # wait for controlsd to be ready msg = messaging.new_message('pandaState') @@ -56,10 +93,10 @@ class TestStartup(unittest.TestCase): pm.send('pandaState', msg) # fingerprint - if car is None: + if (car_model is None) or (fw_versions is not None): finger = {addr: 1 for addr in range(1, 100)} else: - finger = _FINGERPRINTS[car][0] + finger = _FINGERPRINTS[car_model][0] for _ in range(500): msgs = [[addr, 0, b'\x00'*length, 0] for addr, length in finger.items()] @@ -70,10 +107,10 @@ class TestStartup(unittest.TestCase): if len(msgs): event_name = msgs[0].controlsState.alertType.split("/")[0] self.assertEqual(EVENT_NAME[expected_event], event_name, - f"expected {EVENT_NAME[expected_event]} for '{car}', got {event_name}") + f"expected {EVENT_NAME[expected_event]} for '{car_model}', got {event_name}") break else: - self.fail(f"failed to fingerprint {car}") + self.fail(f"failed to fingerprint {car_model}") if __name__ == "__main__": unittest.main() diff --git a/selfdrive/debug/internal/fuzz_fw_fingerprint.py b/selfdrive/debug/internal/fuzz_fw_fingerprint.py new file mode 100755 index 0000000000..1ea133cc19 --- /dev/null +++ b/selfdrive/debug/internal/fuzz_fw_fingerprint.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +# type: ignore +import random +from collections import defaultdict + +from tqdm import tqdm + +from selfdrive.car.fw_versions import match_fw_to_car_fuzzy +from selfdrive.car.toyota.values import FW_VERSIONS as TOYOTA_FW_VERSIONS +from selfdrive.car.honda.values import FW_VERSIONS as HONDA_FW_VERSIONS +from selfdrive.car.hyundai.values import FW_VERSIONS as HYUNDAI_FW_VERSIONS +from selfdrive.car.volkswagen.values import FW_VERSIONS as VW_FW_VERSIONS + + +FWS = {} +FWS.update(TOYOTA_FW_VERSIONS) +FWS.update(HONDA_FW_VERSIONS) +FWS.update(HYUNDAI_FW_VERSIONS) +FWS.update(VW_FW_VERSIONS) + +if __name__ == "__main__": + total = 0 + match = 0 + wrong_match = 0 + confusions = defaultdict(set) + + for _ in tqdm(range(1000)): + for candidate, fws in FWS.items(): + fw_dict = {} + for (tp, addr, subaddr), fw_list in fws.items(): + fw_dict[(addr, subaddr)] = random.choice(fw_list) + + matches = match_fw_to_car_fuzzy(fw_dict, log=False, exclude=candidate) + + total += 1 + if len(matches) == 1: + if list(matches)[0] == candidate: + match += 1 + else: + confusions[candidate] |= matches + wrong_match += 1 + + print() + for candidate, wrong_matches in sorted(confusions.items()): + print(candidate, wrong_matches) + + print() + print(f"Total fuzz cases: {total}") + print(f"Correct matches: {match}") + print(f"Wrong matches: {wrong_match}") + + diff --git a/selfdrive/debug/test_fw_query_on_routes.py b/selfdrive/debug/test_fw_query_on_routes.py index 06ff656db5..ac9a057218 100755 --- a/selfdrive/debug/test_fw_query_on_routes.py +++ b/selfdrive/debug/test_fw_query_on_routes.py @@ -7,7 +7,8 @@ import os import traceback from tqdm import tqdm from tools.lib.logreader import LogReader -from selfdrive.car.fw_versions import match_fw_to_car +from tools.lib.route import Route +from selfdrive.car.fw_versions import match_fw_to_car_exact, match_fw_to_car_fuzzy, build_fw_dict from selfdrive.car.toyota.values import FW_VERSIONS as TOYOTA_FW_VERSIONS from selfdrive.car.honda.values import FW_VERSIONS as HONDA_FW_VERSIONS from selfdrive.car.hyundai.values import FW_VERSIONS as HYUNDAI_FW_VERSIONS @@ -18,6 +19,7 @@ from selfdrive.car.honda.values import FINGERPRINTS as HONDA_FINGERPRINTS from selfdrive.car.hyundai.values import FINGERPRINTS as HYUNDAI_FINGERPRINTS from selfdrive.car.volkswagen.values import FINGERPRINTS as VW_FINGERPRINTS +NO_API = "NO_API" in os.environ SUPPORTED_CARS = list(TOYOTA_FINGERPRINTS.keys()) + list(HONDA_FINGERPRINTS.keys()) + list(HYUNDAI_FINGERPRINTS.keys())+ list(VW_FINGERPRINTS.keys()) if __name__ == "__main__": @@ -33,25 +35,37 @@ if __name__ == "__main__": mismatches = defaultdict(list) - wrong = 0 - good = 0 + not_fingerprinted = 0 + solved_by_fuzzy = 0 + + good_exact = 0 + wrong_fuzzy = 0 + good_fuzzy = 0 dongles = [] for route in tqdm(routes): route = route.rstrip() dongle_id, time = route.split('|') - qlog_path = f"cd:/{dongle_id}/{time}/0/qlog.bz2" if dongle_id in dongles: continue + if NO_API: + qlog_path = f"cd:/{dongle_id}/{time}/0/qlog.bz2" + else: + route = Route(route) + qlog_path = route.qlog_paths()[0] + + if qlog_path is None: + continue + try: lr = LogReader(qlog_path) + dongles.append(dongle_id) for msg in lr: if msg.which() == "pandaState": - if msg.pandaState.pandaType not in ['uno', 'blackPanda']: - dongles.append(dongle_id) + if msg.pandaState.pandaType not in ['uno', 'blackPanda', 'dos']: break elif msg.which() == "carParams": @@ -61,7 +75,6 @@ if __name__ == "__main__": if len(car_fw) == 0: break - dongles.append(dongle_id) live_fingerprint = msg.carParams.carFingerprint if args.car is not None: @@ -70,15 +83,28 @@ if __name__ == "__main__": if live_fingerprint not in SUPPORTED_CARS: break - candidates = match_fw_to_car(car_fw) - if (len(candidates) == 1) and (list(candidates)[0] == live_fingerprint): - good += 1 - print("Correct", live_fingerprint, dongle_id) + fw_versions_dict = build_fw_dict(car_fw) + exact_matches = match_fw_to_car_exact(fw_versions_dict) + fuzzy_matches = match_fw_to_car_fuzzy(fw_versions_dict) + + if (len(exact_matches) == 1) and (list(exact_matches)[0] == live_fingerprint): + good_exact += 1 + print(f"Correct! Live: {live_fingerprint} - Fuzzy: {fuzzy_matches}") + + # Check if fuzzy match was correct + if len(fuzzy_matches) == 1: + if list(fuzzy_matches)[0] != live_fingerprint: + wrong_fuzzy += 1 + print(f"{dongle_id}|{time}") + print("Fuzzy match wrong! Fuzzy:", fuzzy_matches, "Live:", live_fingerprint) + else: + good_fuzzy += 1 break print(f"{dongle_id}|{time}") print("Old style:", live_fingerprint, "Vin", msg.carParams.carVin) - print("New style:", candidates) + print("New style (exact):", exact_matches) + print("New style (fuzzy):", fuzzy_matches) for version in car_fw: subaddr = None if version.subAddress == 0 else hex(version.subAddress) @@ -114,19 +140,24 @@ if __name__ == "__main__": mismatches[live_fingerprint].append(mismatch) print() - wrong += 1 + not_fingerprinted += 1 + + if len(fuzzy_matches) == 1: + if list(fuzzy_matches)[0] == live_fingerprint: + solved_by_fuzzy += 1 + else: + wrong_fuzzy += 1 + print("Fuzzy match wrong! Fuzzy:", fuzzy_matches, "Live:", live_fingerprint) + break except Exception: traceback.print_exc() except KeyboardInterrupt: break - print(f"Fingerprinted: {good} - Not fingerprinted: {wrong}") - print(f"Number of dongle ids checked: {len(dongles)}") print() - # Print FW versions that need to be added seperated out by car and address - for car, m in mismatches.items(): + for car, m in sorted(mismatches.items()): print(car) addrs = defaultdict(list) for (addr, sub_addr, version) in m: @@ -138,3 +169,15 @@ if __name__ == "__main__": print(f" {v},") print(" ]") print() + + print() + print(f"Number of dongle ids checked: {len(dongles)}") + print(f"Fingerprinted: {good_exact}") + print(f"Not fingerprinted: {not_fingerprinted}") + print(f" of which had a fuzzy match: {solved_by_fuzzy}") + + print() + print(f"Correct fuzzy matches: {good_fuzzy}") + print(f"Wrong fuzzy matches: {wrong_fuzzy}") + print() +